feat(terminal): Add "Previously Uploaded Files" table and improve UI

This commit is contained in:
Ahliman HUSEYNOV 2025-11-13 15:47:16 +01:00
parent da454f4b38
commit bba1f47e33
No known key found for this signature in database
3 changed files with 431 additions and 11 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]+)(?:\.(.+))?$/';
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

@ -2,10 +2,12 @@
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;
@ -54,6 +56,7 @@ class FileImport extends Component
public function expirationOptions()
{
return [
5 => '5 minutes',
15 => '15 minutes',
30 => '30 minutes',
60 => '1 hour',
@ -64,6 +67,187 @@ class FileImport extends Component
];
}
#[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();
@ -109,8 +293,21 @@ class FileImport extends Component
// Generate unique file identifier
$uploadId = uniqid('terminal_', true);
$sanitizedFilename = basename($this->filename);
$storageDir = "terminal-uploads/{$uploadId}";
$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
@ -128,14 +325,14 @@ class FileImport extends Component
$finalPath = "{$storagePath}/{$sanitizedFilename}";
$this->uploadedFile->storeAs($storageDir, $sanitizedFilename);
// Copy file to server's temporary directory
$serverTmpPath = "/tmp/coolify_import_{$uploadId}_{$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 = "/tmp/{$sanitizedFilename}";
$containerPath = TerminalFileHelper::generateContainerPath($sanitizedFilename);
$safeContainer = escapeshellarg($this->selectedUuid);
$safeContainerPath = escapeshellarg($containerPath);

View file

@ -1,4 +1,11 @@
<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 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">
@ -51,14 +58,14 @@
>
<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">
<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 text-coolgray-500">
<div class="text-sm">
<span class="font-semibold text-blue-500 hover:text-blue-600">Click to upload</span>
or drag and drop
<span class="text-neutral-600 dark:text-neutral-400"> or drag and drop</span>
</div>
<div class="text-xs text-coolgray-400">
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Any file up to 10GB
</div>
</div>
@ -92,7 +99,21 @@
<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 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
@ -109,5 +130,81 @@
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">
{{ number_format($file['size'] / 1024 / 1024, 2) }} MB
</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>