mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
feat(terminal): Add "Previously Uploaded Files" table and improve UI
This commit is contained in:
parent
da454f4b38
commit
bba1f47e33
3 changed files with 431 additions and 11 deletions
126
app/Helpers/TerminalFileHelper.php
Normal file
126
app/Helpers/TerminalFileHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue