feat: Add terminal file upload functionality

This commit is contained in:
Ahliman HUSEYNOV 2025-11-12 21:30:11 +01:00
parent 7bbfa094d0
commit 72020a7363
No known key found for this signature in database
7 changed files with 544 additions and 22 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException;
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
@ -20,7 +21,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();
@ -69,13 +70,57 @@ class UploadController extends BaseController
]);
}
protected function createFilename(UploadedFile $file)
public function uploadTerminalFile(Request $request)
{
$extension = $file->getClientOriginalExtension();
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
// Security: Verify user has permission to upload terminal files
if (! Auth::check()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$filename .= '_'.md5(time()).'.'.$extension;
// 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);
}
return $filename;
$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);
}
// Use original filename with timestamp to avoid conflicts
$filename = time().'_'.$file->getClientOriginalName();
$file->move($finalPath, $filename);
return response()->json([
'mime_type' => $mime,
'filename' => $filename,
]);
}
}

View file

@ -0,0 +1,80 @@
<?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
$result = instant_remote_process([
"rm -f {$this->serverPath}"
], $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) {
$containerPath = "/tmp/{$this->filename}";
instant_remote_process([
"docker exec {$this->containerUuid} rm -f {$containerPath} 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) && count(scandir($parentDir)) === 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,180 @@
<?php
namespace App\Livewire\Terminal;
use App\Jobs\CleanupExpiredTerminalFilesJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
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 [
15 => '15 minutes',
30 => '30 minutes',
60 => '1 hour',
120 => '2 hours',
240 => '4 hours',
480 => '8 hours',
1440 => '24 hours',
];
}
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);
$sanitizedFilename = basename($this->filename);
$storageDir = "terminal-uploads/{$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);
// Copy file to server's temporary directory
$serverTmpPath = "/tmp/coolify_import_{$uploadId}_{$sanitizedFilename}";
instant_scp($finalPath, $serverTmpPath, $server);
// If it's a container, copy to container
if ($isContainer) {
$containerPath = "/tmp/{$sanitizedFilename}";
instant_remote_process([
"docker cp {$serverTmpPath} {$this->selectedUuid}:{$containerPath}",
], $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,124 @@
<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') }">
<div class="pb-4">
<h2>Import File for Terminal</h2>
<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-coolgray-400" 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 text-coolgray-500">
<span class="font-semibold text-blue-500 hover:text-blue-600">Click to upload</span>
or drag and drop
</div>
<div class="text-xs text-coolgray-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>
<code class="block mt-1 p-2 bg-coolgray-100 dark:bg-coolgray-300 rounded text-xs" x-text="filePath"></code>
</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 -->
<div class="rounded-sm alert-warning text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<div class="font-semibold">Security Notice</div>
<div class="text-xs mt-1">
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.
</div>
</div>
</div>
</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

@ -295,6 +295,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();