Files
jabali-panel/app/Filament/Jabali/Pages/Files.php
2026-02-02 03:11:45 +02:00

1216 lines
47 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\DnsSetting;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\CodeEditor\Enums\Language;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\WithFileUploads;
class Files extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
use WithFileUploads;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationLabel = 'File Manager';
protected static ?int $navigationSort = 6;
protected string $view = 'filament.jabali.pages.files';
public string $currentPath = '';
public bool $showHidden = false;
protected $queryString = ['currentPath' => ['as' => 'path', 'except' => '']];
public array $items = [];
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return 'File Manager';
}
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
public function mount(): void
{
// Check if path is provided in URL
$path = request()->get('path');
if ($path !== null && $path !== '') {
try {
$this->currentPath = $this->sanitizePath((string) $path);
} catch (Exception $e) {
// Invalid path from URL - reset to home directory
$this->currentPath = '';
Notification::make()
->title('Invalid path')
->body('The requested path is not allowed.')
->danger()
->send();
}
}
$this->loadDirectory();
}
protected function getHeaderActions(): array
{
return [
];
}
public function getUsername(): string
{
return Auth::user()->username;
}
public function getMaxUploadSizeMb(): int
{
return (int) DnsSetting::get('max_upload_size_mb', 100);
}
public function showUploadError(): void
{
Notification::make()
->title(__('Upload failed'))
->body(__('File exceeds maximum upload size of :size MB', ['size' => $this->getMaxUploadSizeMb()]))
->danger()
->send();
}
/**
* Sanitize and validate a path to prevent directory traversal attacks.
* Ensures the path stays within the user's home directory.
*
* @param string $path The path to sanitize
* @return string The sanitized path
*
* @throws Exception If the path is invalid or attempts traversal
*/
protected function sanitizePath(string $path): string
{
// Remove null bytes
$path = str_replace("\0", '', $path);
// Normalize directory separators
$path = str_replace('\\', '/', $path);
// Remove leading/trailing slashes and whitespace
$path = trim($path, "/ \t\n\r");
// Block any path containing .. sequences (traversal attempt)
if (preg_match('/(?:^|\/)\.\.(\/|$)/', $path)) {
throw new Exception('Invalid path: directory traversal not allowed');
}
// Block absolute paths
if (str_starts_with($path, '/')) {
throw new Exception('Invalid path: absolute paths not allowed');
}
// Block paths starting with ~
if (str_starts_with($path, '~')) {
throw new Exception('Invalid path: home directory shortcuts not allowed');
}
// Remove redundant slashes and normalize
$path = preg_replace('#/+#', '/', $path);
// Remove . segments
$parts = explode('/', $path);
$parts = array_filter($parts, fn ($part) => $part !== '' && $part !== '.');
return implode('/', $parts);
}
/**
* Sanitize a filename to prevent path injection.
*
* @param string $filename The filename to sanitize
* @return string The sanitized filename
*
* @throws Exception If the filename is invalid
*/
protected function sanitizeFilename(string $filename): string
{
// Remove null bytes
$filename = str_replace("\0", '', $filename);
// Remove any path separators from filename
$filename = basename(str_replace('\\', '/', $filename));
// Block hidden files starting with .. or just .
if ($filename === '' || $filename === '.' || $filename === '..') {
throw new Exception('Invalid filename');
}
// Block filenames that are just dots
if (preg_match('/^\.+$/', $filename)) {
throw new Exception('Invalid filename');
}
return $filename;
}
public function loadDirectory(?string $path = null): void
{
if ($path !== null) {
$this->currentPath = $path;
}
try {
$result = $this->getAgent()->fileList($this->getUsername(), $this->currentPath, $this->showHidden);
$items = $result['items'] ?? [];
// Add ".." entry for navigating up (only if not in root)
if (! empty($this->currentPath)) {
$parentPath = dirname($this->currentPath);
if ($parentPath === '.') {
$parentPath = '';
}
array_unshift($items, [
'name' => '..',
'path' => $parentPath,
'is_dir' => true,
'size' => null,
'modified' => time(),
'permissions' => '----',
'is_parent' => true,
]);
}
$this->items = $items;
} catch (Exception $e) {
$this->items = [];
Notification::make()
->title('Error loading directory')
->body($e->getMessage())
->danger()
->send();
}
}
public function navigateTo(string $path): void
{
try {
$this->currentPath = $this->sanitizePath($path);
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title('Invalid path')
->body($e->getMessage())
->danger()
->send();
}
}
public function navigateUp(): void
{
if (empty($this->currentPath)) {
return;
}
$parts = explode('/', $this->currentPath);
array_pop($parts);
$this->currentPath = implode('/', $parts);
$this->loadDirectory();
$this->resetTable();
}
public function openFolder(string $name): void
{
$newPath = empty($this->currentPath) ? $name : $this->currentPath.'/'.$name;
$this->navigateTo($newPath);
$this->resetTable();
}
public function getPathBreadcrumbs(): array
{
$breadcrumbs = [['name' => 'Home', 'path' => '']];
if (! empty($this->currentPath)) {
$parts = explode('/', $this->currentPath);
$path = '';
foreach ($parts as $part) {
$path = empty($path) ? $part : $path.'/'.$part;
$breadcrumbs[] = ['name' => $part, 'path' => $path];
}
}
return $breadcrumbs;
}
public function table(Table $table): Table
{
return $table
->paginated([100, 250, 500])
->defaultPaginationPageOption(100)
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
$records = collect($this->items)
->mapWithKeys(function (array $item, int $index): array {
$key = $item['path'] ?? $item['name'] ?? (string) $index;
return [$key => $item];
})
->all();
$records = $this->filterRecords($records, $search);
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
return $this->paginateRecords($records, $page, $recordsPerPage);
})
->columns([
TextColumn::make('name')
->label(__('Name'))
->icon(fn (array $record): string => match (true) {
($record['is_parent'] ?? false) => 'heroicon-o-arrow-uturn-left',
$record['is_dir'] => 'heroicon-o-folder',
default => 'heroicon-o-document',
})
->iconColor(fn (array $record): string => match (true) {
($record['is_parent'] ?? false) => 'gray',
$record['is_dir'] => 'warning',
default => 'info',
})
->action(fn (array $record) => $this->navigateTo($record['path']))
->disabledClick(fn (array $record): bool => ! $record['is_dir'])
->weight('medium')
->searchable(),
TextColumn::make('size')
->label(__('Size'))
->formatStateUsing(fn (array $record): string => $record['is_dir'] ? '—' : $this->formatSize($record['size']))
->color('gray'),
TextColumn::make('permissions')
->label(__('Permissions'))
->badge()
->color('gray')
->formatStateUsing(fn (array $record): string => $record['permissions'] ?? '----'),
TextColumn::make('modified')
->label(__('Modified'))
->formatStateUsing(fn (array $record): string => $this->formatDate($record['modified']))
->color('gray'),
])
->recordActions([
Action::make('view')
->label(__('View'))
->icon('heroicon-o-eye')
->color('info')
->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isImage($record['name']))
->modalHeading(fn (array $record): string => basename($record['path']))
->modalWidth('4xl')
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->modalContent(fn (array $record) => view('filament.jabali.components.image-viewer', [
'imageData' => $this->getImageData($record['path']),
'filename' => basename($record['path']),
])),
Action::make('edit')
->label(__('Edit'))
->icon('heroicon-o-pencil-square')
->color('gray')
->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isEditable($record['name']))
->modalHeading(fn (array $record): string => __('Edit').': '.basename($record['path']))
->modalWidth('5xl')
->modalSubmitActionLabel(__('Save Changes'))
->fillForm(function (array $record): array {
try {
$result = $this->getAgent()->fileRead($this->getUsername(), $record['path']);
return ['content' => base64_decode($result['content'])];
} catch (Exception) {
return ['content' => ''];
}
})
->form(fn (array $record): array => [
CodeEditor::make('content')
->label('')
->language($this->getLanguageForFile($record['name']))
->wrap()
->required(),
])
->action(function (array $data, array $record): void {
try {
$this->getAgent()->fileWrite($this->getUsername(), $record['path'], $data['content']);
Notification::make()->title(__('File saved'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Error saving file'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('download')
->label(__('Download'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->visible(fn (array $record): bool => ! $record['is_dir'])
->action(fn (array $record) => $this->downloadFile($record['path'])),
Action::make('extract')
->label(__('Extract'))
->icon('heroicon-o-archive-box-arrow-down')
->color('warning')
->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isExtractable($record['name']))
->requiresConfirmation()
->modalHeading(__('Extract Archive'))
->modalDescription(__('Extract the contents of this archive to the current folder?'))
->modalIcon('heroicon-o-archive-box-arrow-down')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Extract'))
->action(function (array $record): void {
try {
$this->getAgent()->fileExtract($this->getUsername(), $record['path']);
Notification::make()->title(__('Archive extracted successfully'))->success()->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()->title(__('Error extracting archive'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('permissions')
->label(__('Permissions'))
->icon('heroicon-o-lock-closed')
->color('gray')
->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false))
->modalHeading(__('Change Permissions'))
->modalDescription(fn (array $record): string => basename($record['path']))
->modalIcon('heroicon-o-lock-closed')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Apply'))
->fillForm(function (array $record): array {
try {
$result = $this->getAgent()->fileInfo($this->getUsername(), $record['path']);
$perms = $result['info']['permissions'] ?? '0644';
return $this->parsePermissions($perms);
} catch (Exception) {
return $this->parsePermissions('0644');
}
})
->form([
TextInput::make('mode')
->label(__('Numeric Mode'))
->placeholder('755')
->maxLength(4)
->helperText(__('Enter octal mode (e.g., 755, 644)')),
Grid::make(3)
->schema([
\Filament\Schemas\Components\Section::make(__('Owner'))
->schema([
Toggle::make('owner_read')->label(__('Read')),
Toggle::make('owner_write')->label(__('Write')),
Toggle::make('owner_execute')->label(__('Execute')),
]),
\Filament\Schemas\Components\Section::make(__('Group'))
->schema([
Toggle::make('group_read')->label(__('Read')),
Toggle::make('group_write')->label(__('Write')),
Toggle::make('group_execute')->label(__('Execute')),
]),
\Filament\Schemas\Components\Section::make(__('Others'))
->schema([
Toggle::make('other_read')->label(__('Read')),
Toggle::make('other_write')->label(__('Write')),
Toggle::make('other_execute')->label(__('Execute')),
]),
]),
])
->action(function (array $data, array $record): void {
try {
$mode = ! empty($data['mode']) ? $data['mode'] : $this->buildPermissionMode($data);
$this->getAgent()->fileChmod($this->getUsername(), $record['path'], $mode);
Notification::make()->title(__('Permissions changed'))->success()->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()->title(__('Error changing permissions'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('rename')
->label(__('Rename'))
->icon('heroicon-o-pencil')
->color('gray')
->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false))
->modalHeading(__('Rename'))
->form(fn (array $record): array => [
TextInput::make('name')
->label(__('New Name'))
->default($record['name'])
->required(),
])
->action(function (array $data, array $record): void {
try {
$newName = $this->sanitizeFilename($data['name']);
// Build full new path by replacing filename in old path
$oldPath = $record['path'];
$newPath = dirname($oldPath).'/'.$newName;
$this->getAgent()->fileRename($this->getUsername(), $oldPath, $newPath);
Notification::make()->title(__('Renamed successfully'))->success()->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()->title(__('Error renaming'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('moveToTrash')
->label(__('Trash'))
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false))
->requiresConfirmation()
->action(function (array $record): void {
try {
$this->getAgent()->fileTrash($this->getUsername(), $record['path']);
Notification::make()->title(__('Moved to trash'))->success()->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
}),
])
->bulkActions([
\Filament\Actions\BulkAction::make('bulkTrash')
->label(__('Trash'))
->icon('heroicon-o-trash')
->color('danger')
->size('sm')
->requiresConfirmation()
->modalHeading(__('Move to Trash'))
->modalDescription(__('Move selected items to trash? You can restore them later.'))
->modalSubmitActionLabel(__('Move to Trash'))
->modalIcon('heroicon-o-trash')
->modalIconColor('warning')
->deselectRecordsAfterCompletion()
->action(function (\Illuminate\Support\Collection $records): void {
$trashed = 0;
$failed = 0;
foreach ($records as $record) {
try {
$this->getAgent()->fileTrash($this->getUsername(), $record['path']);
$trashed++;
} catch (Exception $e) {
$failed++;
}
}
if ($trashed > 0) {
Notification::make()
->title(__(':count item(s) moved to trash', ['count' => $trashed]))
->success()
->send();
}
if ($failed > 0) {
Notification::make()
->title(__(':count item(s) failed', ['count' => $failed]))
->danger()
->send();
}
$this->loadDirectory();
$this->resetTable();
}),
\Filament\Actions\BulkAction::make('bulkMove')
->label(__('Move'))
->icon('heroicon-o-arrow-right')
->color('warning')
->size('sm')
->modalHeading(__('Move Selected Items'))
->modalDescription(__('Select the destination folder'))
->modalSubmitActionLabel(__('Move'))
->form([
TextInput::make('destination')
->label(__('Destination Path'))
->placeholder(__('e.g., domains/example.com/public_html'))
->required()
->helperText(__('Enter the path relative to your home directory')),
])
->deselectRecordsAfterCompletion()
->action(function (\Illuminate\Support\Collection $records, array $data): void {
$moved = 0;
$failed = 0;
$destination = trim($data['destination'], '/');
foreach ($records as $record) {
try {
$filename = basename($record['path']);
$newPath = $destination.'/'.$filename;
$this->getAgent()->fileMove($this->getUsername(), $record['path'], $newPath);
$moved++;
} catch (Exception $e) {
$failed++;
}
}
if ($moved > 0) {
Notification::make()
->title(__(':count item(s) moved', ['count' => $moved]))
->success()
->send();
}
if ($failed > 0) {
Notification::make()
->title(__(':count item(s) failed to move', ['count' => $failed]))
->danger()
->send();
}
$this->loadDirectory();
$this->resetTable();
}),
\Filament\Actions\BulkAction::make('bulkCopy')
->label(__('Copy'))
->icon('heroicon-o-document-duplicate')
->color('info')
->size('sm')
->modalHeading(__('Copy Selected Items'))
->modalDescription(__('Select the destination folder'))
->modalSubmitActionLabel(__('Copy'))
->form([
TextInput::make('destination')
->label(__('Destination Path'))
->placeholder(__('e.g., domains/example.com/public_html'))
->required()
->helperText(__('Enter the path relative to your home directory')),
])
->deselectRecordsAfterCompletion()
->action(function (\Illuminate\Support\Collection $records, array $data): void {
$copied = 0;
$failed = 0;
$destination = trim($data['destination'], '/');
foreach ($records as $record) {
try {
$filename = basename($record['path']);
$newPath = $destination.'/'.$filename;
$this->getAgent()->fileCopy($this->getUsername(), $record['path'], $newPath);
$copied++;
} catch (Exception $e) {
$failed++;
}
}
if ($copied > 0) {
Notification::make()
->title(__(':count item(s) copied', ['count' => $copied]))
->success()
->send();
}
if ($failed > 0) {
Notification::make()
->title(__(':count item(s) failed to copy', ['count' => $failed]))
->danger()
->send();
}
$this->loadDirectory();
$this->resetTable();
}),
])
->checkIfRecordIsSelectableUsing(fn (array $record): bool => ! ($record['is_parent'] ?? false))
->headerActions([
$this->newFolderAction(),
$this->newFileAction(),
$this->uploadAction(),
$this->trashAction(),
Action::make('toggleHidden')
->label($this->showHidden ? __('Hide Hidden') : __('Show Hidden'))
->icon($this->showHidden ? 'heroicon-o-eye-slash' : 'heroicon-o-eye')
->color($this->showHidden ? 'warning' : 'gray')
->action(function () {
$this->showHidden = ! $this->showHidden;
$this->loadDirectory();
$this->resetTable();
}),
Action::make('refreshTable')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function () {
$this->loadDirectory();
$this->resetTable();
}),
])
->emptyStateHeading(__('This folder is empty'))
->emptyStateDescription(__('Create a new file or folder to get started'))
->emptyStateIcon('heroicon-o-folder-open')
->striped();
}
public function getTableRecordKey(Model|array $record): string
{
return is_array($record) ? $record['path'] : $record->getKey();
}
protected function filterRecords(array $records, ?string $search): array
{
if (! $search) {
return $records;
}
$needle = Str::lower($search);
return array_filter($records, function (array $record) use ($needle): bool {
$name = (string) ($record['name'] ?? '');
return Str::contains(Str::lower($name), $needle);
});
}
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
{
if (! $sortColumn) {
return $records;
}
$parent = null;
$parentKey = null;
foreach ($records as $key => $record) {
if (($record['is_parent'] ?? false) === true) {
$parent = $record;
$parentKey = $key;
unset($records[$key]);
break;
}
}
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
uasort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
$aValue = $a[$sortColumn] ?? null;
$bValue = $b[$sortColumn] ?? null;
if (is_numeric($aValue) && is_numeric($bValue)) {
$result = (float) $aValue <=> (float) $bValue;
} else {
$result = strcmp((string) $aValue, (string) $bValue);
}
return $direction === 'asc' ? $result : -$result;
});
if ($parent !== null && $parentKey !== null) {
$records = [$parentKey => $parent] + $records;
}
return $records;
}
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
{
$page = max(1, (int) $page);
$perPage = max(1, (int) $recordsPerPage);
$total = count($records);
$items = array_slice($records, ($page - 1) * $perPage, $perPage, true);
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => request()->url(),
'pageName' => $this->getTablePaginationPageName(),
],
);
}
// Drag and drop operations
public function moveItem(string $sourcePath, string $destPath): void
{
try {
$this->getAgent()->fileMove($this->getUsername(), $sourcePath, $destPath);
Notification::make()
->title('Item moved successfully')
->success()
->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title('Error moving item')
->body($e->getMessage())
->danger()
->send();
}
}
public function uploadDroppedFile(string $filename, string $base64Content): void
{
try {
// Sanitize filename to prevent path traversal
$filename = $this->sanitizeFilename($filename);
$maxSizeMb = $this->getMaxUploadSizeMb();
$maxSizeBytes = $maxSizeMb * 1024 * 1024;
// Base64 is ~33% larger than original, so decode first then check size
$content = base64_decode($base64Content);
if (strlen($content) > $maxSizeBytes) {
throw new Exception(__('File too large (max :size MB)', ['size' => $maxSizeMb]));
}
$this->getAgent()->fileUpload(
$this->getUsername(),
$this->currentPath,
$filename,
$content
);
Notification::make()
->title("Uploaded: $filename")
->success()
->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title("Upload failed: $filename")
->body($e->getMessage())
->danger()
->send();
}
}
// Header Actions
protected function newFolderAction(): Action
{
return Action::make('newFolder')
->label(__('New Folder'))
->icon('heroicon-o-folder-plus')
->modalHeading(__('Create New Folder'))
->modalDescription(__('Enter a name for the new folder'))
->modalIcon('heroicon-o-folder-plus')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Create Folder'))
->form([
TextInput::make('name')
->label(__('Folder Name'))
->placeholder(__('my-folder'))
->required()
->autocomplete(false)
->maxLength(255)
->helperText(__('Use letters, numbers, hyphens, and underscores')),
])
->action(function (array $data): void {
try {
$folderName = $this->sanitizeFilename($data['name']);
$path = empty($this->currentPath)
? $folderName
: $this->currentPath.'/'.$folderName;
$this->getAgent()->fileMkdir($this->getUsername(), $path);
Notification::make()
->title(__('Folder created'))
->success()
->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title(__('Error creating folder'))
->body($e->getMessage())
->danger()
->send();
}
});
}
protected function newFileAction(): Action
{
return Action::make('newFile')
->label(__('New File'))
->icon('heroicon-o-document-plus')
->modalHeading(__('Create New File'))
->modalDescription(__('Create a new file with optional content'))
->modalIcon('heroicon-o-document-plus')
->modalIconColor('info')
->modalSubmitActionLabel(__('Create File'))
->modalWidth('2xl')
->form([
TextInput::make('name')
->label(__('File Name'))
->placeholder(__('example.php'))
->required()
->autocomplete(false)
->maxLength(255)
->helperText(__('Include the file extension (e.g., .php, .html, .txt)')),
Textarea::make('content')
->label(__('Content'))
->placeholder(__('Enter file content here...'))
->rows(12)
->extraAttributes(['class' => 'font-mono text-sm']),
])
->action(function (array $data): void {
try {
$fileName = $this->sanitizeFilename($data['name']);
$path = empty($this->currentPath)
? $fileName
: $this->currentPath.'/'.$fileName;
$this->getAgent()->fileWrite($this->getUsername(), $path, $data['content'] ?? '');
Notification::make()
->title(__('File created'))
->success()
->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title(__('Error creating file'))
->body($e->getMessage())
->danger()
->send();
}
});
}
protected function uploadAction(): Action
{
$maxSizeMb = $this->getMaxUploadSizeMb();
return Action::make('upload')
->label(__('Upload'))
->icon('heroicon-o-arrow-up-tray')
->modalHeading(__('Upload Files'))
->modalDescription(__('Select files to upload to the current folder'))
->modalIcon('heroicon-o-arrow-up-tray')
->modalIconColor('success')
->modalSubmitActionLabel(__('Upload'))
->form([
FileUpload::make('files')
->label(__('Select Files'))
->multiple()
->storeFiles(false)
->required()
->maxSize($maxSizeMb * 1024) // Convert MB to KB for Filament
->validationMessages([
'max' => __('File exceeds maximum upload size of :size MB', ['size' => $maxSizeMb]),
])
->helperText(__('Maximum file size: :size MB', ['size' => $maxSizeMb])),
])
->action(function (array $data): void {
$uploaded = 0;
foreach ($data['files'] ?? [] as $file) {
try {
$filename = $this->sanitizeFilename($file->getClientOriginalName());
$content = file_get_contents($file->getRealPath());
$this->getAgent()->fileUpload(
$this->getUsername(),
$this->currentPath,
$filename,
$content
);
$uploaded++;
} catch (Exception $e) {
Notification::make()
->title(__('Upload failed: ').$file->getClientOriginalName())
->body($e->getMessage())
->danger()
->send();
}
}
if ($uploaded > 0) {
Notification::make()
->title(__(':count file(s) uploaded', ['count' => $uploaded]))
->success()
->send();
$this->loadDirectory();
$this->resetTable();
}
});
}
protected function refreshAction(): Action
{
return Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function () {
$this->loadDirectory();
$this->resetTable();
});
}
// Helper to get CodeEditor language based on file extension
protected function getLanguageForFile(string $filename): ?Language
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// Handle .blade.php
if (str_ends_with(strtolower($filename), '.blade.php')) {
return Language::Php;
}
return match ($ext) {
'php' => Language::Php,
'js', 'mjs', 'cjs' => Language::JavaScript,
'json' => Language::Json,
'html', 'htm' => Language::Html,
'css', 'scss', 'sass' => Language::Css,
'xml' => Language::Xml,
'sql' => Language::Sql,
'md', 'markdown' => Language::Markdown,
'yml', 'yaml' => Language::Yaml,
'py' => Language::Python,
'java' => Language::Java,
'go' => Language::Go,
'cpp', 'c', 'h', 'hpp' => Language::Cpp,
default => null,
};
}
// Row Actions
public function downloadFile(string $path): void
{
try {
$result = $this->getAgent()->fileRead($this->getUsername(), $path);
$this->dispatch('download-file',
content: base64_encode(base64_decode($result['content'])),
filename: basename($path)
);
} catch (Exception $e) {
Notification::make()->title('Error downloading')->body($e->getMessage())->danger()->send();
}
}
public function formatSize(?int $bytes): string
{
if ($bytes === null) {
return '—';
}
if ($bytes < 1024) {
return $bytes.' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1).' KB';
}
if ($bytes < 1073741824) {
return round($bytes / 1048576, 1).' MB';
}
return round($bytes / 1073741824, 1).' GB';
}
public function formatDate(int $timestamp): string
{
return date('M d, Y H:i', $timestamp);
}
public function isEditable(string $name): bool
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$editable = ['php', 'js', 'ts', 'css', 'scss', 'html', 'htm', 'json', 'xml', 'txt', 'md', 'yml', 'yaml', 'env', 'htaccess', 'conf', 'ini', 'sh', 'bash', 'log', 'sql', 'vue', 'jsx', 'tsx'];
return in_array($ext, $editable) || empty($ext);
}
public function isExtractable(string $name): bool
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$extractable = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'rar', '7z'];
if (preg_match('/\.(tar\.gz|tar\.bz2|tar\.xz)$/i', $name)) {
return true;
}
return in_array($ext, $extractable);
}
public function isImage(string $name): bool
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']);
}
public function getImageData(string $path): ?string
{
try {
$result = $this->getAgent()->fileRead($this->getUsername(), $path);
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'bmp' => 'image/bmp',
'ico' => 'image/x-icon',
];
$mime = $mimeTypes[$ext] ?? 'image/png';
return 'data:'.$mime.';base64,'.$result['content'];
} catch (Exception $e) {
return null;
}
}
public function parsePermissions(string $mode): array
{
$mode = ltrim($mode, '0');
$mode = str_pad($mode, 3, '0', STR_PAD_LEFT);
$owner = (int) ($mode[0] ?? 0);
$group = (int) ($mode[1] ?? 0);
$other = (int) ($mode[2] ?? 0);
return [
'mode' => $mode,
'owner_read' => (bool) ($owner & 4),
'owner_write' => (bool) ($owner & 2),
'owner_execute' => (bool) ($owner & 1),
'group_read' => (bool) ($group & 4),
'group_write' => (bool) ($group & 2),
'group_execute' => (bool) ($group & 1),
'other_read' => (bool) ($other & 4),
'other_write' => (bool) ($other & 2),
'other_execute' => (bool) ($other & 1),
];
}
public function buildPermissionMode(array $data): string
{
$owner = 0;
$group = 0;
$other = 0;
if ($data['owner_read'] ?? false) {
$owner += 4;
}
if ($data['owner_write'] ?? false) {
$owner += 2;
}
if ($data['owner_execute'] ?? false) {
$owner += 1;
}
if ($data['group_read'] ?? false) {
$group += 4;
}
if ($data['group_write'] ?? false) {
$group += 2;
}
if ($data['group_execute'] ?? false) {
$group += 1;
}
if ($data['other_read'] ?? false) {
$other += 4;
}
if ($data['other_write'] ?? false) {
$other += 2;
}
if ($data['other_execute'] ?? false) {
$other += 1;
}
return "{$owner}{$group}{$other}";
}
protected function trashAction(): Action
{
return Action::make('trash')
->label(__('Trash'))
->icon('heroicon-o-trash')
->color('danger')
->modalHeading(__('Trash'))
->modalDescription(__('View and manage deleted items'))
->modalIcon('heroicon-o-trash')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->modalContent(fn () => view('filament.jabali.components.trash-table-embed'));
}
public function getTrashItems(): array
{
try {
$result = $this->getAgent()->fileListTrash($this->getUsername());
return $result['items'] ?? [];
} catch (Exception) {
return [];
}
}
public function restoreFromTrash(string $trashName): void
{
try {
$result = $this->getAgent()->fileRestore($this->getUsername(), $trashName);
Notification::make()
->title(__('Restored'))
->body(__('Restored to: :path', ['path' => $result['restored_path'] ?? '']))
->success()
->send();
$this->loadDirectory();
$this->resetTable();
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
}
public function deleteFromTrash(string $trashName): void
{
try {
$trashPath = ".trash/$trashName";
$this->getAgent()->fileDelete($this->getUsername(), $trashPath);
Notification::make()->title(__('Permanently deleted'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
}
public function emptyTrash(): void
{
try {
$result = $this->getAgent()->fileEmptyTrash($this->getUsername());
Notification::make()
->title(__('Trash emptied'))
->body(__(':count items deleted', ['count' => $result['deleted'] ?? 0]))
->success()
->send();
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
}
}