This commit is contained in:
Ahliman HUSEYNOV 2026-03-09 19:38:24 +01:00 committed by GitHub
commit d008dec352
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1003 additions and 21 deletions

View file

@ -0,0 +1,126 @@
<?php
namespace App\Helpers;
class TerminalFileHelper
{
/**
* Generate a safe filename with embedded metadata
*
* Format: {uploadedAt}_{expiresAt}_{serverId}_{containerUuid}_{originalName}_{hash}.{ext}
* Example: 1699900000_1699903600_123_abc123def456_demo-file_a1b2c3d4.txt
*/
public static function generateFilename(
string $originalFilename,
int $expiresAt,
int $serverId,
?string $containerUuid = null
): string {
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
$nameWithoutExt = pathinfo($originalFilename, PATHINFO_FILENAME);
// Create safe slug from original filename
$safeSlug = \Illuminate\Support\Str::slug($nameWithoutExt);
$safeSlug = substr($safeSlug, 0, 30); // Limit length
// Sanitize extension (only allow alphanumeric)
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
// Generate hash for uniqueness
$hash = \Illuminate\Support\Str::random(8);
// Build filename parts
$uploadedAt = time();
$containerPart = $containerUuid ? substr($containerUuid, 0, 12) : 'nocontainer';
// Format: uploadedAt_expiresAt_serverId_containerUuid_originalName_hash.ext
return sprintf(
'%d_%d_%d_%s_%s_%s%s',
$uploadedAt,
$expiresAt,
$serverId,
$containerPart,
$safeSlug,
$hash,
$safeExtension ? '.' . $safeExtension : ''
);
}
/**
* Parse filename to extract metadata
*
* Returns array with: uploaded_at, expires_at, server_id, container_uuid, original_name, hash, extension
* Returns null if filename doesn't match expected format
*/
public static function parseFilename(string $filename): ?array
{
// Pattern: uploadedAt_expiresAt_serverId_containerUuid_originalName_hash.ext
$pattern = '/^(\d+)_(\d+)_(\d+)_([^_]+)_([^_]+)_([a-zA-Z0-9]+)(?:\.([a-zA-Z0-9]+))?$/';
if (!preg_match($pattern, $filename, $matches)) {
return null;
}
return [
'uploaded_at' => (int) $matches[1],
'expires_at' => (int) $matches[2],
'server_id' => (int) $matches[3],
'container_uuid' => $matches[4] === 'nocontainer' ? null : $matches[4],
'original_name' => str_replace('-', ' ', $matches[5]),
'hash' => $matches[6],
'extension' => $matches[7] ?? null,
];
}
/**
* Generate server path for the uploaded file
*/
public static function generateServerPath(string $filename): string
{
// Just use the filename directly - it already contains all metadata
return "/tmp/{$filename}";
}
/**
* Generate container path for the uploaded file
*/
public static function generateContainerPath(string $filename): string
{
// Use same filename in container
return "/tmp/{$filename}";
}
/**
* Check if file is expired
*/
public static function isExpired(string $filename): bool
{
$metadata = self::parseFilename($filename);
if (!$metadata) {
return false;
}
return time() > $metadata['expires_at'];
}
/**
* Get display name from filename
*/
public static function getDisplayName(string $filename): string
{
$metadata = self::parseFilename($filename);
if (!$metadata) {
return $filename;
}
$displayName = ucwords($metadata['original_name']);
if ($metadata['extension']) {
$displayName .= '.' . $metadata['extension'];
}
return $displayName;
}
}

View file

@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException;
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
@ -21,7 +23,7 @@ class UploadController extends BaseController
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
throw new UploadMissingFileException;
throw new UploadMissingFileException();
}
$save = $receiver->receive();
@ -73,13 +75,72 @@ class UploadController extends BaseController
]);
}
protected function createFilename(UploadedFile $file)
public function uploadTerminalFile(Request $request)
{
// Security: Verify user has permission to upload terminal files
if (! Auth::check()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// Check if user is admin or has terminal access
$user = Auth::user();
if (! $user->isAdmin() && ! $user->isInstanceAdmin()) {
return response()->json(['error' => 'You do not have permission to upload terminal files'], 403);
}
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
throw new UploadMissingFileException();
}
$save = $receiver->receive();
if ($save->isFinished()) {
return $this->saveTerminalFile($save->getFile());
}
$handler = $save->handler();
return response()->json([
'done' => $handler->getPercentageDone(),
'status' => true,
]);
}
protected function saveTerminalFile(UploadedFile $file)
{
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = 'terminal-uploads/temp';
$finalPath = storage_path('app/'.$filePath);
// Create directory if it doesn't exist
if (! is_dir($finalPath)) {
mkdir($finalPath, 0755, true);
}
// Security: Generate safe filename server-side to prevent path traversal
$originalName = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension();
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
$filename .= '_'.md5(time()).'.'.$extension;
// Create a safe slug from original filename (without extension)
$nameWithoutExt = pathinfo($originalName, PATHINFO_FILENAME);
$safeSlug = Str::slug($nameWithoutExt); // Converts to lowercase, replaces special chars with dashes
$safeSlug = substr($safeSlug, 0, 50); // Limit length
return $filename;
// Sanitize extension (only allow alphanumeric)
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
// Generate safe filename: timestamp_slug_randomhash.ext
$randomHash = Str::random(16);
$safeFilename = time().'_'.$safeSlug.'_'.$randomHash.($safeExtension ? '.'.$safeExtension : '');
$file->move($finalPath, $safeFilename);
return response()->json([
'mime_type' => $mime,
'filename' => $safeFilename,
'original_name' => $originalName, // Keep original name for reference
]);
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CleanupExpiredTerminalFilesJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $timeout = 300; // 5 minutes
public function __construct(
public string $localPath,
public string $serverPath,
public int $serverId,
public ?string $containerUuid,
public string $filename
) {
}
public function handle(): void
{
try {
// Delete local file
if (file_exists($this->localPath)) {
unlink($this->localPath);
Log::info("Cleaned up local terminal file: {$this->localPath}");
}
// Delete file from server
$server = Server::find($this->serverId);
if ($server) {
// Remove from server - escape shell arguments to prevent injection
$escapedServerPath = escapeshellarg($this->serverPath);
$result = instant_remote_process([
"rm -f {$escapedServerPath}"
], $server, throwError: false);
if ($result) {
Log::info("Cleaned up server terminal file: {$this->serverPath}");
}
// If container was specified, remove from container as well
if ($this->containerUuid) {
$escapedContainerUuid = escapeshellarg($this->containerUuid);
$escapedFilename = escapeshellarg($this->filename);
$containerPath = "/tmp/{$this->filename}"; // For logging only
instant_remote_process([
"docker exec {$escapedContainerUuid} rm -f /tmp/{$escapedFilename} 2>/dev/null || true"
], $server, throwError: false);
Log::info("Cleaned up container terminal file: {$containerPath}");
}
}
// Clean up empty parent directory
$parentDir = dirname($this->localPath);
if (is_dir($parentDir)) {
$contents = scandir($parentDir);
if ($contents !== false && count($contents) === 2) { // Only . and ..
rmdir($parentDir);
}
}
} catch (\Throwable $e) {
Log::error("Failed to cleanup terminal file: {$e->getMessage()}", [
'localPath' => $this->localPath,
'serverPath' => $this->serverPath,
'serverId' => $this->serverId,
]);
// Don't fail the job, just log the error
// Files will eventually be cleaned up by manual cleanup or system maintenance
}
}
}

View file

@ -0,0 +1,380 @@
<?php
namespace App\Livewire\Terminal;
use App\Helpers\TerminalFileHelper;
use App\Jobs\CleanupExpiredTerminalFilesJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithFileUploads;
class FileImport extends Component
{
use AuthorizesRequests;
use WithFileUploads;
public string $selectedUuid;
public ?string $selectedServerUuid = null;
public ?string $targetName = null;
public $uploadedFile;
public ?string $filename = null;
public ?string $filesize = null;
public ?string $filePath = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
public int $expirationMinutes = 60; // Default: 1 hour
public function updatedUploadedFile()
{
$this->validate([
'uploadedFile' => 'required|file|max:10485760', // 10GB in KB
]);
$this->filename = $this->uploadedFile->getClientOriginalName();
$this->filesize = number_format($this->uploadedFile->getSize() / 1024 / 1024, 2) . ' MB';
$this->isUploading = false;
$this->dispatch('success', 'File uploaded successfully!');
}
#[Computed]
public function expirationOptions()
{
return [
5 => '5 minutes',
15 => '15 minutes',
30 => '30 minutes',
60 => '1 hour',
120 => '2 hours',
240 => '4 hours',
480 => '8 hours',
1440 => '24 hours',
];
}
#[Computed]
public function uploadedFiles()
{
$files = [];
$baseDir = storage_path('app/terminal-uploads');
$currentUserId = Auth::id();
if (! is_dir($baseDir)) {
return $files;
}
// Scan subdirectories for current user's uploaded files
$directories = glob($baseDir . '/user_' . $currentUserId . '_*', GLOB_ONLYDIR);
// Collect all server IDs to load them in bulk
$serverIds = [];
$filesData = [];
foreach ($directories as $dir) {
$dirFiles = glob($dir . '/*');
foreach ($dirFiles as $filePath) {
if (is_file($filePath)) {
$filename = basename($filePath);
// Parse metadata from filename
$metadata = TerminalFileHelper::parseFilename($filename);
if (!$metadata) {
// Skip files that don't match our format
continue;
}
$serverIds[] = $metadata['server_id'];
$filesData[] = [
'filename' => $filename,
'display_name' => TerminalFileHelper::getDisplayName($filename),
'directory' => basename($dir),
'size' => filesize($filePath),
'uploaded_at' => $metadata['uploaded_at'],
'expires_at' => $metadata['expires_at'],
'server_id' => $metadata['server_id'],
'container_uuid' => $metadata['container_uuid'],
];
}
}
}
// Load all servers in one query
$servers = Server::whereIn('id', array_unique($serverIds))->get()->keyBy('id');
// Add server name to each file
foreach ($filesData as $fileData) {
$server = $servers->get($fileData['server_id']);
$fileData['server_name'] = $server ? $server->name : 'Unknown';
$files[] = $fileData;
}
// Sort by upload date (newest first)
usort($files, function ($a, $b) {
return $b['uploaded_at'] <=> $a['uploaded_at'];
});
return $files;
}
public function copyPath(string $directory, string $filename)
{
// Security: validate inputs to prevent path traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false ||
strpos($directory, '..') !== false || strpos($directory, '/') !== false) {
$this->dispatch('error', 'Invalid filename or directory');
return;
}
// Security: verify directory belongs to current user
$currentUserId = Auth::id();
if (! str_starts_with($directory, 'user_' . $currentUserId . '_')) {
$this->dispatch('error', 'Unauthorized access');
return;
}
// Parse metadata from filename
$metadata = TerminalFileHelper::parseFilename($filename);
if (!$metadata) {
$this->dispatch('error', 'Invalid file format');
return;
}
// Generate server path
$serverPath = TerminalFileHelper::generateServerPath($filename);
// Return the actual server path
$this->dispatch('path-copied', path: $serverPath);
$this->dispatch('success', 'Path copied to clipboard!');
}
public function deleteFile(string $directory, string $filename)
{
// Security: validate inputs to prevent path traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false ||
strpos($directory, '..') !== false || strpos($directory, '/') !== false) {
$this->dispatch('error', 'Invalid filename or directory');
return;
}
// Security: verify directory belongs to current user
$currentUserId = Auth::id();
if (! str_starts_with($directory, 'user_' . $currentUserId . '_')) {
$this->dispatch('error', 'Unauthorized access');
return;
}
try {
// Parse metadata from filename
$metadata = TerminalFileHelper::parseFilename($filename);
if (!$metadata) {
$this->dispatch('error', 'Invalid file format');
return;
}
// Find and delete the file
$baseDir = storage_path('app/terminal-uploads');
$dir = $baseDir . '/' . $directory;
$filePath = $dir . '/' . $filename;
if (! file_exists($filePath)) {
$this->dispatch('error', 'File not found');
return;
}
// Security: double-check the full path contains user ID
$realPath = realpath($filePath);
$realBaseDir = realpath($baseDir);
if ($realPath === false || $realBaseDir === false ||
! str_starts_with($realPath, $realBaseDir . '/user_' . $currentUserId . '_')) {
$this->dispatch('error', 'Unauthorized access');
return;
}
// Delete local file
unlink($filePath);
// Delete remote files if server exists
$server = Server::find($metadata['server_id']);
if ($server) {
// Generate server path
$serverPath = TerminalFileHelper::generateServerPath($filename);
$escapedServerPath = escapeshellarg($serverPath);
instant_remote_process([
"rm -f {$escapedServerPath}"
], $server, throwError: false);
// Delete from container if applicable
if ($metadata['container_uuid']) {
$escapedContainerUuid = escapeshellarg($metadata['container_uuid']);
$containerPath = TerminalFileHelper::generateContainerPath($filename);
$escapedContainerPath = escapeshellarg($containerPath);
instant_remote_process([
"docker exec {$escapedContainerUuid} rm -f {$escapedContainerPath} 2>/dev/null || true"
], $server, throwError: false);
}
}
// Try to delete the empty directory
$files = scandir($dir);
if (count($files) === 2) { // Only . and ..
rmdir($dir);
}
$this->dispatch('success', 'File deleted successfully!');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},FileUploadCompleted" => 'handleUploadCompleted',
];
}
public function mount(string $selectedUuid, string $targetName, ?string $selectedServerUuid = null)
{
$this->selectedUuid = $selectedUuid;
$this->targetName = $targetName;
$this->selectedServerUuid = $selectedServerUuid;
}
public function handleUploadCompleted()
{
// Refresh the component after upload
$this->dispatch('success', 'File uploaded successfully!');
}
public function generateFilePath()
{
if (empty($this->filename)) {
$this->dispatch('error', 'No file uploaded yet.');
return;
}
try {
// Determine server UUID
$serverUuid = $this->selectedServerUuid ?? $this->selectedUuid;
$server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->first();
if (! $server) {
$this->dispatch('error', 'Server not found.');
return;
}
$isContainer = ($this->selectedServerUuid && $this->selectedUuid !== $this->selectedServerUuid);
// Generate unique file identifier
$uploadId = uniqid('terminal_', true);
$originalFilename = basename($this->filename);
$userId = Auth::id();
// Calculate expiration timestamp
$expiresAt = now()->addMinutes($this->expirationMinutes)->timestamp;
// Generate filename with embedded metadata
$sanitizedFilename = TerminalFileHelper::generateFilename(
$originalFilename,
$expiresAt,
$server->id,
$isContainer ? $this->selectedUuid : null
);
$storageDir = "terminal-uploads/user_{$userId}_{$uploadId}";
$storagePath = storage_path("app/{$storageDir}");
// Get the uploaded file from Livewire
if (! $this->uploadedFile) {
$this->dispatch('error', 'Uploaded file not found. Please try uploading again.');
return;
}
// Create directory and store file
if (! file_exists($storagePath)) {
mkdir($storagePath, 0755, true);
}
$finalPath = "{$storagePath}/{$sanitizedFilename}";
$this->uploadedFile->storeAs($storageDir, $sanitizedFilename);
// Generate server path
$serverTmpPath = TerminalFileHelper::generateServerPath($sanitizedFilename);
$safeServerTmpPath = escapeshellarg($serverTmpPath);
instant_scp($finalPath, $safeServerTmpPath, $server);
// If it's a container, copy to container
if ($isContainer) {
$containerPath = TerminalFileHelper::generateContainerPath($sanitizedFilename);
$safeContainer = escapeshellarg($this->selectedUuid);
$safeContainerPath = escapeshellarg($containerPath);
instant_remote_process([
"docker cp {$safeServerTmpPath} {$safeContainer}:{$safeContainerPath}",
], $server);
$this->filePath = $containerPath;
} else {
$this->filePath = $serverTmpPath;
}
// Schedule cleanup job
CleanupExpiredTerminalFilesJob::dispatch(
$finalPath,
$serverTmpPath,
$server->id,
$isContainer ? $this->selectedUuid : null,
$sanitizedFilename
)->delay(now()->addMinutes($this->expirationMinutes));
$this->dispatch('success', "File ready! Path: {$this->filePath}");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.terminal.file-import');
}
/**
* Workaround for Livewire toJSON serialization issue
* This prevents "Public method [toJSON] not found" errors
*/
public function toJSON()
{
return json_encode([
'selectedUuid' => $this->selectedUuid,
'targetName' => $this->targetName,
'selectedServerUuid' => $this->selectedServerUuid,
]);
}
}

View file

@ -16,6 +16,8 @@ class Index extends Component
public bool $isLoadingContainers = true;
public bool $showImportModal = false;
public function mount()
{
$this->servers = Server::isReachable()->get()->filter(function ($server) {
@ -84,6 +86,58 @@ class Index extends Component
);
}
public function getTargetName()
{
if ($this->selected_uuid === 'default') {
return '';
}
// Check if it's a server
$server = collect($this->servers)->firstWhere('uuid', $this->selected_uuid);
if ($server) {
return $server->name.' (Server)';
}
// Otherwise it's a container
$container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid);
if ($container) {
return $container['name'];
}
return '';
}
public function getServerUuid()
{
if ($this->selected_uuid === 'default') {
return null;
}
// Check if it's a server
$server = collect($this->servers)->firstWhere('uuid', $this->selected_uuid);
if ($server) {
return $server->uuid;
}
// Otherwise it's a container, get its server
$container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid);
if ($container) {
return $container['server_uuid'];
}
return null;
}
public function openImportModal()
{
$this->showImportModal = true;
}
public function closeImportModal()
{
$this->showImportModal = false;
}
public function render()
{
return view('livewire.terminal.index');

View file

@ -0,0 +1,25 @@
<?php
/**
* Format bytes to human-readable size
*
* @param int|float $bytes The size in bytes
* @param int $precision The number of decimal places
* @return string Formatted size with unit (B, KB, MB, GB, TB)
*/
function formatBytes($bytes, int $precision = 2): string
{
$bytes = (int) $bytes;
if ($bytes < 1024) {
return $bytes . ' B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$exp = floor(log($bytes) / log(1024));
$exp = min($exp, count($units) - 1);
$value = $bytes / pow(1024, $exp);
return round($value, $precision) . ' ' . $units[$exp];
}

View file

@ -0,0 +1,210 @@
<div x-data="{
error: $wire.entangle('error'),
filesize: $wire.entangle('filesize'),
filename: $wire.entangle('filename'),
isUploading: $wire.entangle('isUploading'),
progress: $wire.entangle('progress'),
filePath: $wire.entangle('filePath')
}" @path-copied.window="navigator.clipboard.writeText($event.detail.path)">
<div class="pb-4">
<div class="text-sm text-neutral-500 pb-2">
Upload a file that will be temporarily stored and accessible in your selected server or container.
Perfect for importing SQL dumps, configuration files, or any other data.
</div>
</div>
<div class="space-y-4">
<!-- Target Display -->
@if ($targetName)
<div class="rounded-sm bg-coolgray-100 dark:bg-coolgray-200 p-3">
<div class="text-sm">
<span class="font-semibold">Target:</span> {{ $targetName }}
</div>
</div>
@endif
<!-- Expiration Time Selection -->
<div class="w-full lg:w-64">
<x-forms.select
id="expirationMinutes"
label="File Expiration"
wire:model="expirationMinutes">
@foreach ($this->expirationOptions as $minutes => $label)
<option value="{{ $minutes }}">{{ $label }}</option>
@endforeach
</x-forms.select>
<div class="text-xs text-neutral-500 mt-1">
File will be automatically deleted after this time for security.
</div>
</div>
<!-- File Upload -->
<div>
<h3 class="pb-2">Upload File</h3>
<div class="border-2 border-dashed border-coolgray-300 dark:border-coolgray-400 rounded-lg p-6 text-center hover:border-coolgray-400 dark:hover:border-coolgray-300 transition-colors">
<input
type="file"
wire:model="uploadedFile"
id="file-upload"
class="hidden"
@change="
const file = $event.target.files[0];
if (file) {
filename = file.name;
filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
}
"
>
<label for="file-upload" class="cursor-pointer">
<div class="flex flex-col items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div class="text-sm">
<span class="font-semibold text-blue-500 hover:text-blue-600">Click to upload</span>
<span class="text-neutral-600 dark:text-neutral-400"> or drag and drop</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Any file up to 10GB
</div>
</div>
</label>
</div>
</div>
<!-- Upload Progress -->
<div x-show="isUploading" x-cloak>
<div class="text-sm pb-1">Uploading: <span x-text="Math.round(progress)"></span>%</div>
<progress max="100" x-bind:value="progress" class="progress progress-warning w-full"></progress>
</div>
<!-- File Information -->
<div x-show="filename && !error && !filePath" x-cloak class="rounded-sm bg-coolgray-100 dark:bg-coolgray-200 p-4">
<h3 class="pb-2">File Uploaded</h3>
<div class="space-y-1 text-sm">
<div><span class="font-semibold">Filename:</span> <span x-text="filename"></span></div>
<div><span class="font-semibold">Size:</span> <span x-text="filesize"></span></div>
</div>
<x-forms.button
class="mt-4 w-full"
wire:click='generateFilePath'>
Generate File Path & Copy to Target
</x-forms.button>
</div>
<!-- File Path Result -->
<div x-show="filePath" x-cloak class="rounded-sm bg-success/10 border border-success p-4">
<h3 class="pb-2 text-success">File Ready!</h3>
<div class="space-y-2">
<div class="text-sm">
<span class="font-semibold">File Path:</span>
<div class="mt-1 relative" x-data="{ copied: false }">
<code class="block p-2 pr-12 bg-coolgray-100 dark:bg-coolgray-300 rounded text-xs" x-text="filePath"></code>
<button
@click.prevent="copied = true; navigator.clipboard.writeText(filePath); setTimeout(() => copied = false, 1000)"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors cursor-pointer"
title="Copy to clipboard">
<svg x-show="!copied" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<div class="text-sm">
<span class="font-semibold">Expires in:</span> {{ $expirationMinutes }} minutes
</div>
<div class="text-xs text-neutral-600 dark:text-neutral-400 pt-2">
Copy the file path above and use it in your terminal commands.
The file will be automatically deleted after expiration.
</div>
</div>
</div>
<!-- Security Notice -->
<x-callout type="warning" title="Security Notice">
Uploaded files are stored temporarily and will be automatically deleted after the expiration time.
Do not upload sensitive files without encryption. Always verify file permissions after upload.
</x-callout>
<!-- Previously Uploaded Files -->
@if (count($this->uploadedFiles) > 0)
<div class="pt-4">
<h3 class="pb-2">Previously Uploaded Files</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-coolgray-300 dark:border-coolgray-600">
<tr class="text-left">
<th class="pb-2 pr-4">File Name</th>
<th class="pb-2 pr-4">Server</th>
<th class="pb-2 pr-4">Size</th>
<th class="pb-2 pr-4">Uploaded</th>
<th class="pb-2 pr-4">Expires</th>
<th class="pb-2">Actions</th>
</tr>
</thead>
<tbody>
@foreach ($this->uploadedFiles as $file)
<tr class="border-b border-coolgray-200 dark:border-coolgray-700">
<td class="py-3 pr-4">
<div class="font-medium">{{ $file['display_name'] }}</div>
<div class="text-xs text-neutral-500">{{ $file['filename'] }}</div>
</td>
<td class="py-3 pr-4">
<div class="font-medium">{{ $file['server_name'] }}</div>
@if($file['container_uuid'])
<div class="text-xs text-neutral-500">Container: {{ substr($file['container_uuid'], 0, 12) }}</div>
@endif
</td>
<td class="py-3 pr-4">
{{ formatBytes($file['size']) }}
</td>
<td class="py-3 pr-4">
{{ \Carbon\Carbon::createFromTimestamp($file['uploaded_at'])->diffForHumans() }}
</td>
<td class="py-3 pr-4">
@if($file['expires_at'])
@php
$expiresAt = \Carbon\Carbon::createFromTimestamp($file['expires_at']);
$now = \Carbon\Carbon::now();
@endphp
@if($now->gt($expiresAt))
<span class="text-red-500">Expired</span>
@else
<span>{{ $expiresAt->diffForHumans() }}</span>
@endif
@else
<span class="text-neutral-500">N/A</span>
@endif
</td>
<td class="py-3">
<div class="flex gap-2">
<x-forms.button
wire:click="copyPath('{{ $file['directory'] }}', '{{ $file['filename'] }}')"
class="cursor-pointer"
title="Copy remote file path">
Copy Path
</x-forms.button>
<x-forms.button
isError
wire:click="deleteFile('{{ $file['directory'] }}', '{{ $file['filename'] }}')"
wire:confirm="Are you sure you want to delete this file?"
class="cursor-pointer"
title="Delete file">
Delete
</x-forms.button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
</div>

View file

@ -15,27 +15,65 @@
</div>
@else
@if ($servers->count() > 0)
<form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row"
wire:submit="$dispatchSelf('connectToContainer')">
<x-forms.select id="selected_uuid" required wire:model.live="selected_uuid">
<option value="default">Select a server or container</option>
@foreach ($servers as $server)
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif
<div class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row">
<form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row flex-1"
wire:submit="$dispatchSelf('connectToContainer')">
<x-forms.select id="selected_uuid" required wire:model.live="selected_uuid">
<option value="default">Select a server or container</option>
@foreach ($servers as $server)
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif
@endforeach
@endforeach
@endforeach
</x-forms.select>
<x-forms.button type="submit">Connect</x-forms.button>
</form>
</x-forms.select>
<x-forms.button type="submit">Connect</x-forms.button>
</form>
@if ($selected_uuid !== 'default')
<x-forms.button wire:click="openImportModal">Import File</x-forms.button>
@endif
</div>
@else
<div>No servers with terminal access found.</div>
@endif
@endif
<livewire:project.shared.terminal />
</div>
@if ($showImportModal && $selected_uuid !== 'default')
<div class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4"
@keydown.window.escape="$wire.closeImportModal()">
<div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"
wire:click="closeImportModal">
</div>
<div class="relative w-full border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit max-h-[calc(100vh-2rem)] bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">Import File to Terminal</h3>
<button wire:click="closeImportModal"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative flex items-center justify-center w-auto overflow-y-auto px-6 pb-6">
@php
$targetName = $this->getTargetName();
$serverUuid = $this->getServerUuid();
@endphp
<livewire:terminal.file-import
:selectedUuid="$selected_uuid"
:targetName="$targetName"
:selectedServerUuid="$serverUuid"
:key="'file-import-'.$selected_uuid"
/>
</div>
</div>
</div>
@endif
</div>

View file

@ -304,6 +304,7 @@ Route::middleware(['auth'])->group(function () {
Route::middleware(['auth'])->group(function () {
Route::post('/upload/backup/{databaseUuid}', [UploadController::class, 'upload'])->name('upload.backup');
Route::post('/upload/terminal', [UploadController::class, 'uploadTerminalFile'])->name('upload.terminal');
Route::get('/download/backup/{executionId}', function () {
try {
$user = auth()->user();