*/ public array $entries = []; public function mount(): void { // listing loads lazily via wire:init -> load() } /** Read the active server's directory over SSH; empty + offline on failure. */ public function load(): void { $this->entries = []; $this->connected = false; $active = $this->activeServer(); if ($active && $active->credential_exists) { try { $this->entries = app(FleetService::class)->files($active, $this->path); $this->connected = true; } catch (Throwable) { $this->connected = false; } } $this->ready = true; } public function open(int $index): void { $name = $this->entries[$index]['name'] ?? null; if ($name === null) { return; } $this->path = rtrim($this->path, '/').'/'.$name; $this->load(); } public function go(int $index): void { $path = $this->crumbs[$index]['path'] ?? '/'; $this->path = $path !== '' ? $path : '/'; $this->load(); } public function up(): void { $this->path = dirname($this->path) ?: '/'; $this->load(); } /** Upload the selected file into the current directory (SFTP put). */ public function updatedUpload(FleetService $fleet): void { abort_unless(auth()->user()?->can('operate'), 403); $this->validate(['upload' => ['file', 'max:51200']]); // 50 MB $active = $this->activeServer(); if ($active && $active->credential_exists && $this->upload) { try { $name = basename($this->upload->getClientOriginalName()); $fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath()); $this->dispatch('notify', message: __('files.uploaded', ['name' => $name])); } catch (Throwable $e) { $this->dispatch('notify', message: __('files.upload_failed', ['error' => $e->getMessage()])); } } $this->reset('upload'); $this->load(); } /** Stream a remote file to the browser as a download (SFTP get). */ public function download(int $index, FleetService $fleet) { $name = $this->entries[$index]['name'] ?? null; $active = $this->activeServer(); if ($name === null || ! $active || ! $active->credential_exists) { return null; } try { $content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name)); } catch (Throwable $e) { $this->dispatch('notify', message: __('files.download_failed', ['error' => $e->getMessage()])); return null; } return response()->streamDownload(fn () => print ($content), basename($name)); } /** Open the view/edit modal for a file. */ public function edit(int $index): void { $name = $this->entries[$index]['name'] ?? null; $active = $this->activeServer(); if ($name === null || ! $active) { return; } $this->dispatch('openModal', component: 'modals.file-editor', arguments: [ 'serverId' => $active->id, 'path' => rtrim($this->path, '/').'/'.basename($name), 'name' => basename($name), ], ); } #[On('fileSaved')] public function reloadAfterSave(): void { $this->load(); } /** * Breadcrumb segments derived from $path (root first, then each folder). * * @return array */ public function getCrumbsProperty(): array { $crumbs = [['label' => '/', 'path' => '/']]; $acc = ''; foreach (array_filter(explode('/', $this->path)) as $segment) { $acc .= '/'.$segment; $crumbs[] = ['label' => $segment, 'path' => $acc]; } return $crumbs; } /** * Delete a file (R5): opens the confirm modal, which writes the AuditEvent * and re-dispatches `fileConfirmed` — handled by deleteEntry(), which performs * the SFTP unlink over the active server's connection. */ public function confirmDelete(int $index): void { abort_unless(auth()->user()?->can('operate'), 403); $name = $this->entries[$index]['name'] ?? null; if ($name === null) { return; } // Seal the FULL path + the server id so the token cannot be retargeted to // another directory (client-held $path) or host (session-selected server). $fullPath = rtrim($this->path, '/')."/{$name}"; $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('files.delete_heading'), 'body' => __('files.delete_body', ['name' => $name, 'path' => $this->path]), 'confirmLabel' => __('common.delete'), 'danger' => true, 'icon' => 'trash', 'notify' => __('files.delete_notify', ['name' => $name]), 'token' => ConfirmToken::issue( 'fileConfirmed', ['path' => $fullPath], 'file.delete', $fullPath, $this->activeServer()?->id, ), ], ); } /** Applies the confirmed deletion over SFTP, then reloads the directory. */ #[On('fileConfirmed')] public function deleteEntry(string $confirmToken, FleetService $fleet): void { abort_unless(auth()->user()?->can('operate'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'fileConfirmed'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $active = $this->activeServer(); if (! $active || ($payload['serverId'] ?? null) !== $active->id) { return; // token sealed for a different server — refuse to retarget } // Delete the SEALED path, never a client-recombined one. $path = $payload['params']['path']; if ($active->credential_exists) { try { $fleet->deleteFile($active, $path); } catch (Throwable $e) { $this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()])); return; } } $this->load(); } public function render() { return view('livewire.files.index')->title(__('files.title')); } }