mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat(terminal): add secure uploaded file lifecycle for imports
Persist terminal uploads, add server upload UI and import selection flow, and schedule cleanup for expired or stale files. Tighten upload security, replace ad hoc file cleanup with tracked deletion logic, and cover the new behavior with feature and unit tests.
This commit is contained in:
1 parent
bdeff06d01
commit
514a100a60
23 files changed
+1794
-550
No files matched your search
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Terminal;
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Models\Server;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeleteTerminalUploadedFile
|
||||
{
|
||||
public function __invoke(TerminalUploadedFile $terminalUploadedFile): void
|
||||
{
|
||||
DB::transaction(function () use ($terminalUploadedFile): void {
|
||||
$terminalUploadedFile->forceFill([
|
||||
'status' => TerminalUploadedFileStatus::Deleting,
|
||||
'cleanup_attempts' => $terminalUploadedFile->cleanup_attempts + 1,
|
||||
'last_cleanup_error' => null,
|
||||
])->save();
|
||||
});
|
||||
|
||||
try {
|
||||
$server = ! is_null($terminalUploadedFile->server_id) ? Server::find($terminalUploadedFile->server_id) : null;
|
||||
|
||||
if ($server && filled($terminalUploadedFile->server_path)) {
|
||||
$this->deleteServerFile($server, $terminalUploadedFile->server_path);
|
||||
} elseif (! is_null($terminalUploadedFile->server_id)) {
|
||||
Log::warning('Skipping remote terminal file cleanup because server is missing.', [
|
||||
'terminal_uploaded_file_id' => $terminalUploadedFile->id,
|
||||
'server_id' => $terminalUploadedFile->server_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($server && filled($terminalUploadedFile->container_uuid) && filled($terminalUploadedFile->container_path)) {
|
||||
$this->deleteContainerFile($server, $terminalUploadedFile->container_uuid, $terminalUploadedFile->container_path);
|
||||
}
|
||||
|
||||
$this->deleteLocalFile($terminalUploadedFile->local_path);
|
||||
$this->cleanupParentDirectories($terminalUploadedFile->local_path);
|
||||
|
||||
DB::transaction(function () use ($terminalUploadedFile): void {
|
||||
$terminalUploadedFile->forceFill([
|
||||
'status' => TerminalUploadedFileStatus::Deleted,
|
||||
'deleted_at' => now(),
|
||||
'last_cleanup_error' => null,
|
||||
])->save();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
DB::transaction(function () use ($terminalUploadedFile, $e): void {
|
||||
$terminalUploadedFile->forceFill([
|
||||
'status' => TerminalUploadedFileStatus::DeleteFailed,
|
||||
'last_cleanup_error' => $e->getMessage(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteServerFile(Server $server, string $serverPath): void
|
||||
{
|
||||
$escapedServerPath = escapeshellarg($serverPath);
|
||||
|
||||
instant_remote_process([
|
||||
"rm -f -- {$escapedServerPath}",
|
||||
], $server, throwError: false);
|
||||
|
||||
$remoteStillExists = instant_remote_process([
|
||||
"test ! -e {$escapedServerPath} && echo deleted || echo exists",
|
||||
], $server, throwError: false);
|
||||
|
||||
if ($remoteStillExists !== 'deleted') {
|
||||
throw new \RuntimeException("Failed to delete remote terminal file: {$serverPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteContainerFile(Server $server, string $containerUuid, string $containerPath): void
|
||||
{
|
||||
$escapedContainerUuid = escapeshellarg($containerUuid);
|
||||
$escapedContainerPath = escapeshellarg($containerPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec -u 0 {$escapedContainerUuid} rm -f -- {$escapedContainerPath} 2>/dev/null || true",
|
||||
], $server, throwError: false);
|
||||
|
||||
$containerDeletionState = instant_remote_process([
|
||||
"if docker container inspect {$escapedContainerUuid} >/dev/null 2>&1; then docker exec -u 0 {$escapedContainerUuid} test ! -e {$escapedContainerPath} 2>/dev/null && echo deleted || echo exists; else echo deleted; fi",
|
||||
], $server, throwError: false);
|
||||
|
||||
if ($containerDeletionState !== 'deleted') {
|
||||
throw new \RuntimeException("Failed to delete container terminal file: {$containerPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteLocalFile(?string $localPath): void
|
||||
{
|
||||
if (blank($localPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($localPath)) {
|
||||
unlink($localPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupParentDirectories(?string $localPath): void
|
||||
{
|
||||
if (blank($localPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedRoots = array_filter([
|
||||
realpath(storage_path('app/terminal-uploads')),
|
||||
realpath(storage_path('app/terminal-uploads-pending')),
|
||||
]);
|
||||
|
||||
$currentDirectory = dirname($localPath);
|
||||
|
||||
while ($currentDirectory && $currentDirectory !== '.' && $currentDirectory !== DIRECTORY_SEPARATOR) {
|
||||
$realCurrentDirectory = realpath($currentDirectory);
|
||||
|
||||
if ($realCurrentDirectory === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$isWithinAllowedRoot = collect($allowedRoots)->contains(
|
||||
fn (string $root): bool => str_starts_with($realCurrentDirectory, $root)
|
||||
);
|
||||
|
||||
if (! $isWithinAllowedRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
$contents = scandir($realCurrentDirectory);
|
||||
if ($contents === false || count($contents) > 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
rmdir($realCurrentDirectory);
|
||||
$currentDirectory = dirname($realCurrentDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Terminal\DeleteTerminalUploadedFile;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CleanupTerminalUploads extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:terminal-uploads {--pending-hours=24} {--chunk=100}';
|
||||
|
||||
protected $description = 'Clean up expired and stale terminal uploads.';
|
||||
|
||||
public function handle(DeleteTerminalUploadedFile $deleteTerminalUploadedFile): int
|
||||
{
|
||||
$chunkSize = max((int) $this->option('chunk'), 1);
|
||||
$pendingHours = max((int) $this->option('pending-hours'), 1);
|
||||
|
||||
$expiredCount = 0;
|
||||
$stalePendingCount = 0;
|
||||
|
||||
TerminalUploadedFile::query()
|
||||
->expiredForCleanup()
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function (Collection $files) use ($deleteTerminalUploadedFile, &$expiredCount): void {
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$deleteTerminalUploadedFile($file);
|
||||
$expiredCount++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Failed to clean expired terminal upload {$file->uuid}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TerminalUploadedFile::query()
|
||||
->pendingForCleanup($pendingHours)
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function (Collection $files) use ($deleteTerminalUploadedFile, &$stalePendingCount): void {
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$deleteTerminalUploadedFile($file);
|
||||
$stalePendingCount++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Failed to clean pending terminal upload {$file->uuid}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Cleaned {$expiredCount} expired terminal uploads.");
|
||||
$this->info("Cleaned {$stalePendingCount} stale pending terminal uploads.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ class Kernel extends ConsoleKernel
|
||||
|
||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||
$this->scheduleInstance->command('cleanup:terminal-uploads')->hourly()->withoutOverlapping()->onOneServer();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TerminalUploadedFileStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Active = 'active';
|
||||
case Deleting = 'deleting';
|
||||
case Deleted = 'deleted';
|
||||
case DeleteFailed = 'delete_failed';
|
||||
}
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException;
|
||||
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
|
||||
@@ -13,7 +17,7 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
|
||||
|
||||
class UploadController extends BaseController
|
||||
{
|
||||
public function upload(Request $request)
|
||||
public function upload(Request $request): JsonResponse
|
||||
{
|
||||
$databaseIdentifier = request()->route('databaseUuid');
|
||||
$resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id'));
|
||||
@@ -23,15 +27,12 @@ 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();
|
||||
|
||||
if ($save->isFinished()) {
|
||||
// Use the original identifier from the route to maintain path consistency
|
||||
// For ServiceDatabase: {name}-{service_uuid}
|
||||
// For standalone databases: {uuid}
|
||||
return $this->saveFile($save->getFile(), $databaseIdentifier);
|
||||
}
|
||||
|
||||
@@ -42,30 +43,10 @@ class UploadController extends BaseController
|
||||
'status' => true,
|
||||
]);
|
||||
}
|
||||
// protected function saveFileToS3($file)
|
||||
// {
|
||||
// $fileName = $this->createFilename($file);
|
||||
|
||||
// $disk = Storage::disk('s3');
|
||||
// // It's better to use streaming Streaming (laravel 5.4+)
|
||||
// $disk->putFileAs('photos', $file, $fileName);
|
||||
|
||||
// // for older laravel
|
||||
// // $disk->put($fileName, file_get_contents($file), 'public');
|
||||
// $mime = str_replace('/', '-', $file->getMimeType());
|
||||
|
||||
// // We need to delete the file when uploaded to s3
|
||||
// unlink($file->getPathname());
|
||||
|
||||
// return response()->json([
|
||||
// 'path' => $disk->url($fileName),
|
||||
// 'name' => $fileName,
|
||||
// 'mime_type' => $mime
|
||||
// ]);
|
||||
// }
|
||||
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
|
||||
protected function saveFile(UploadedFile $file, string $resourceIdentifier): JsonResponse
|
||||
{
|
||||
$mime = str_replace('/', '-', $file->getMimeType());
|
||||
$mime = str_replace('/', '-', (string) $file->getMimeType());
|
||||
$filePath = "upload/{$resourceIdentifier}";
|
||||
$finalPath = storage_path('app/'.$filePath);
|
||||
$file->move($finalPath, 'restore');
|
||||
@@ -75,29 +56,24 @@ class UploadController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadTerminalFile(Request $request)
|
||||
public function uploadTerminalFile(Request $request): JsonResponse
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
Gate::authorize('canAccessTerminal');
|
||||
|
||||
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||
|
||||
if ($receiver->isUploaded() === false) {
|
||||
throw new UploadMissingFileException();
|
||||
throw new UploadMissingFileException;
|
||||
}
|
||||
|
||||
$save = $receiver->receive();
|
||||
|
||||
if ($save->isFinished()) {
|
||||
return $this->saveTerminalFile($save->getFile());
|
||||
return $this->saveTerminalFile($save->getFile(), $request);
|
||||
}
|
||||
|
||||
$handler = $save->handler();
|
||||
@@ -108,39 +84,75 @@ class UploadController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function saveTerminalFile(UploadedFile $file)
|
||||
protected function saveTerminalFile(UploadedFile $file, Request $request): JsonResponse
|
||||
{
|
||||
$mime = str_replace('/', '-', $file->getMimeType());
|
||||
$filePath = 'terminal-uploads/temp';
|
||||
$user = Auth::user();
|
||||
$teamId = data_get($user?->currentTeam(), 'id');
|
||||
|
||||
if (blank($teamId)) {
|
||||
return response()->json(['error' => 'No active team found.'], 422);
|
||||
}
|
||||
|
||||
$mime = str_replace('/', '-', (string) $file->getMimeType());
|
||||
$size = (int) $file->getSize();
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
|
||||
$uploadToken = (string) $request->input('resumableIdentifier', Str::uuid()->toString());
|
||||
$uploadToken = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadToken) ?: Str::uuid()->toString();
|
||||
|
||||
$filePath = sprintf('terminal-uploads-pending/user_%d/%s', $user->id, $uploadToken);
|
||||
$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();
|
||||
|
||||
// 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
|
||||
|
||||
// Sanitize extension (only allow alphanumeric)
|
||||
$safeSlug = Str::slug($nameWithoutExt);
|
||||
$safeSlug = substr($safeSlug, 0, 50);
|
||||
$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);
|
||||
|
||||
$localPath = $finalPath.'/'.$safeFilename;
|
||||
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'team_id' => $teamId,
|
||||
'upload_token' => $uploadToken,
|
||||
],
|
||||
[
|
||||
'original_name' => $originalName,
|
||||
'stored_filename' => $safeFilename,
|
||||
'mime_type' => $mime,
|
||||
'size_bytes' => $size,
|
||||
'local_path' => $localPath,
|
||||
'server_id' => null,
|
||||
'server_path' => null,
|
||||
'container_uuid' => null,
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Pending,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => null,
|
||||
'finalized_at' => null,
|
||||
'deleted_at' => null,
|
||||
'last_cleanup_error' => null,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'done' => 100,
|
||||
'status' => true,
|
||||
'file_uuid' => $terminalUploadedFile->uuid,
|
||||
'mime_type' => $mime,
|
||||
'filename' => $safeFilename,
|
||||
'original_name' => $originalName, // Keep original name for reference
|
||||
'size' => $size,
|
||||
'upload_token' => $uploadToken,
|
||||
'stored_filename' => $safeFilename,
|
||||
'original_name' => $originalName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
{
|
||||
@@ -26,8 +25,7 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue
|
||||
public int $serverId,
|
||||
public ?string $containerUuid,
|
||||
public string $filename
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
@@ -43,25 +41,35 @@ class CleanupExpiredTerminalFilesJob implements ShouldQueue
|
||||
if ($server) {
|
||||
// Remove from server - escape shell arguments to prevent injection
|
||||
$escapedServerPath = escapeshellarg($this->serverPath);
|
||||
$result = instant_remote_process([
|
||||
"rm -f {$escapedServerPath}"
|
||||
instant_remote_process([
|
||||
"rm -f -- {$escapedServerPath}",
|
||||
], $server, throwError: false);
|
||||
|
||||
if ($result) {
|
||||
$remoteStillExists = instant_remote_process([
|
||||
"if [ -e {$escapedServerPath} ]; then echo exists; fi",
|
||||
], $server, throwError: false);
|
||||
|
||||
if ($remoteStillExists !== 'exists') {
|
||||
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
|
||||
$escapedContainerPath = escapeshellarg($containerPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$escapedContainerUuid} rm -f /tmp/{$escapedFilename} 2>/dev/null || true"
|
||||
"docker exec -u 0 {$escapedContainerUuid} rm -f -- {$escapedContainerPath} 2>/dev/null || true",
|
||||
], $server, throwError: false);
|
||||
|
||||
Log::info("Cleaned up container terminal file: {$containerPath}");
|
||||
$containerStillExists = instant_remote_process([
|
||||
"docker exec -u 0 {$escapedContainerUuid} test ! -e {$escapedContainerPath} 2>/dev/null && echo deleted || echo exists",
|
||||
], $server, throwError: false);
|
||||
|
||||
if ($containerStillExists === 'deleted') {
|
||||
Log::info("Cleaned up container terminal file: {$containerPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Upload extends Component
|
||||
{
|
||||
public Server $server;
|
||||
|
||||
public array $servers = [];
|
||||
|
||||
public array $containers = [];
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
Gate::authorize('canAccessTerminal');
|
||||
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->servers = [
|
||||
[
|
||||
'uuid' => $this->server->uuid,
|
||||
'name' => $this->server->name,
|
||||
],
|
||||
];
|
||||
$this->containers = $this->runningContainers();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.upload');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, uuid: string, server_uuid: string}>
|
||||
*/
|
||||
private function runningContainers(): array
|
||||
{
|
||||
if (! $this->server->isFunctional() || ! $this->server->isTerminalEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->server
|
||||
->loadAllContainers()
|
||||
->map(function (array $container): ?array {
|
||||
$state = data_get_str($container, 'State')->lower();
|
||||
if (! $state->contains('running')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = data_get($container, 'Names');
|
||||
if (blank($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'uuid' => $name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->sortBy('name', SORT_NATURAL)
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,29 @@
|
||||
|
||||
namespace App\Livewire\Terminal;
|
||||
|
||||
use App\Actions\Terminal\DeleteTerminalUploadedFile;
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Helpers\TerminalFileHelper;
|
||||
use App\Jobs\CleanupExpiredTerminalFilesJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class FileImport extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithFileUploads;
|
||||
|
||||
public string $selectedUuid;
|
||||
public string $selectedUuid = 'default';
|
||||
|
||||
public ?string $selectedServerUuid = null;
|
||||
#[Locked]
|
||||
public array $servers = [];
|
||||
|
||||
public ?string $targetName = null;
|
||||
|
||||
public $uploadedFile;
|
||||
#[Locked]
|
||||
public array $containers = [];
|
||||
|
||||
public ?string $filename = null;
|
||||
|
||||
@@ -37,25 +38,28 @@ class FileImport extends Component
|
||||
|
||||
public bool $error = false;
|
||||
|
||||
public int $expirationMinutes = 60; // Default: 1 hour
|
||||
public int $expirationMinutes = 60;
|
||||
|
||||
public function updatedUploadedFile()
|
||||
public bool $hasPendingUpload = false;
|
||||
|
||||
public ?string $pendingUploadUuid = null;
|
||||
|
||||
public ?string $pendingDeleteFileUuid = null;
|
||||
|
||||
public function mount(array $servers, array $containers, string $selectedUuid = 'default'): void
|
||||
{
|
||||
$this->validate([
|
||||
'uploadedFile' => 'required|file|max:10485760', // 10GB in KB
|
||||
]);
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$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!');
|
||||
$this->servers = $servers;
|
||||
$this->containers = $containers;
|
||||
$this->selectedUuid = $this->isSelectableTarget($selectedUuid) ? $selectedUuid : 'default';
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function expirationOptions()
|
||||
public function expirationOptions(): array
|
||||
{
|
||||
return [
|
||||
1 => '1 minute',
|
||||
5 => '5 minutes',
|
||||
15 => '15 minutes',
|
||||
30 => '30 minutes',
|
||||
@@ -68,276 +72,269 @@ class FileImport extends Component
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function uploadedFiles()
|
||||
public function uploadedFiles(): array
|
||||
{
|
||||
$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;
|
||||
return TerminalUploadedFile::query()
|
||||
->ownedByCurrentUserAndTeam()
|
||||
->visible()
|
||||
->with('server:id,name')
|
||||
->orderByDesc('uploaded_at')
|
||||
->get()
|
||||
->map(function (TerminalUploadedFile $terminalUploadedFile): array {
|
||||
return [
|
||||
'uuid' => $terminalUploadedFile->uuid,
|
||||
'filename' => $terminalUploadedFile->stored_filename,
|
||||
'display_name' => $terminalUploadedFile->original_name ?: TerminalFileHelper::getDisplayName($terminalUploadedFile->stored_filename),
|
||||
'size' => $terminalUploadedFile->size_bytes,
|
||||
'uploaded_at' => $terminalUploadedFile->uploaded_at?->timestamp,
|
||||
'expires_at' => $terminalUploadedFile->expires_at?->timestamp,
|
||||
'server_name' => $terminalUploadedFile->server?->name ?? 'Unknown',
|
||||
'container_uuid' => $terminalUploadedFile->container_uuid,
|
||||
'path' => $terminalUploadedFile->container_path ?: $terminalUploadedFile->server_path,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function copyPath(string $directory, string $filename)
|
||||
#[Computed]
|
||||
public function targetName(): ?string
|
||||
{
|
||||
// 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 data_get($this->resolveSelectedTarget(), 'label');
|
||||
}
|
||||
|
||||
public function updatedSelectedUuid(string $value): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
if ($value === 'default') {
|
||||
$this->filePath = null;
|
||||
$this->error = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: verify directory belongs to current user
|
||||
$currentUserId = Auth::id();
|
||||
if (! str_starts_with($directory, 'user_' . $currentUserId . '_')) {
|
||||
$this->dispatch('error', 'Unauthorized access');
|
||||
if (! $this->isSelectableTarget($value)) {
|
||||
$this->selectedUuid = 'default';
|
||||
$this->dispatchError('Invalid target selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse metadata from filename
|
||||
$metadata = TerminalFileHelper::parseFilename($filename);
|
||||
$this->filePath = null;
|
||||
$this->error = false;
|
||||
}
|
||||
|
||||
public function registerUploadedFile(string $fileUuid, string $originalName, int $size): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findPendingUploadForCurrentUser($fileUuid);
|
||||
|
||||
if (! $terminalUploadedFile) {
|
||||
$this->dispatchError('Uploaded file not found. Please try again.');
|
||||
|
||||
if (!$metadata) {
|
||||
$this->dispatch('error', 'Invalid file format');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate server path
|
||||
$serverPath = TerminalFileHelper::generateServerPath($filename);
|
||||
$this->error = false;
|
||||
$this->pendingUploadUuid = $terminalUploadedFile->uuid;
|
||||
$this->filename = basename($originalName);
|
||||
$this->filesize = formatBytes($size);
|
||||
$this->filePath = null;
|
||||
$this->hasPendingUpload = true;
|
||||
$this->isUploading = false;
|
||||
$this->progress = 100;
|
||||
|
||||
// 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()
|
||||
public function resetPendingUpload(): void
|
||||
{
|
||||
if (empty($this->filename)) {
|
||||
$this->dispatch('error', 'No file uploaded yet.');
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findPendingUploadForCurrentUser($this->pendingUploadUuid);
|
||||
if ($terminalUploadedFile) {
|
||||
app(DeleteTerminalUploadedFile::class)($terminalUploadedFile);
|
||||
}
|
||||
|
||||
$this->clearUploadState();
|
||||
}
|
||||
|
||||
public function handleUploadError(string $message): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
$this->dispatchError($message);
|
||||
}
|
||||
|
||||
public function copyPath(string $fileUuid): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid);
|
||||
|
||||
if (! $terminalUploadedFile) {
|
||||
$this->dispatchError('File not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $terminalUploadedFile->container_path ?: $terminalUploadedFile->server_path;
|
||||
|
||||
if (blank($path)) {
|
||||
$this->dispatchError('File path is unavailable.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('path-copied', path: $path);
|
||||
$this->dispatch('success', 'Path copied to clipboard!');
|
||||
}
|
||||
|
||||
public function prepareFileDeletion(string $fileUuid): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid);
|
||||
|
||||
if (! $terminalUploadedFile) {
|
||||
$this->pendingDeleteFileUuid = null;
|
||||
$this->dispatchError('File not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pendingDeleteFileUuid = $terminalUploadedFile->uuid;
|
||||
}
|
||||
|
||||
public function confirmDeleteFile(): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findVisibleFileForCurrentUser((string) $this->pendingDeleteFileUuid);
|
||||
|
||||
if (! $terminalUploadedFile) {
|
||||
$this->pendingDeleteFileUuid = null;
|
||||
$this->dispatchError('File not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteTerminalUploadedFile($terminalUploadedFile);
|
||||
$this->pendingDeleteFileUuid = null;
|
||||
}
|
||||
|
||||
public function deleteFile(string $fileUuid): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$terminalUploadedFile = $this->findVisibleFileForCurrentUser($fileUuid);
|
||||
|
||||
if (! $terminalUploadedFile) {
|
||||
$this->dispatchError('File not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteTerminalUploadedFile($terminalUploadedFile);
|
||||
}
|
||||
|
||||
private function deleteTerminalUploadedFile(TerminalUploadedFile $terminalUploadedFile): void
|
||||
{
|
||||
try {
|
||||
app(DeleteTerminalUploadedFile::class)($terminalUploadedFile);
|
||||
$this->error = false;
|
||||
$this->dispatch('success', 'File deleted successfully!');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generateFilePath(): void
|
||||
{
|
||||
$this->authorizeTerminalAccess();
|
||||
|
||||
$target = $this->resolveSelectedTarget();
|
||||
if (! $target) {
|
||||
$this->dispatchError('Please select a server or container.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pendingUpload = $this->findPendingUploadForCurrentUser($this->pendingUploadUuid);
|
||||
|
||||
if (! $pendingUpload || blank($this->filename)) {
|
||||
$this->dispatchError('No file uploaded yet.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine server UUID
|
||||
$serverUuid = $this->selectedServerUuid ?? $this->selectedUuid;
|
||||
$server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->first();
|
||||
$server = Server::ownedByCurrentTeam()->whereUuid($target['server_uuid'])->first();
|
||||
|
||||
if (! $server) {
|
||||
$this->dispatch('error', 'Server not found.');
|
||||
$this->dispatchError('Server not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$isContainer = ($this->selectedServerUuid && $this->selectedUuid !== $this->selectedServerUuid);
|
||||
if (! is_file($pendingUpload->local_path)) {
|
||||
$this->dispatchError('Uploaded file not found. Please upload it again.');
|
||||
|
||||
// Generate unique file identifier
|
||||
$uploadId = uniqid('terminal_', true);
|
||||
$originalFilename = basename($this->filename);
|
||||
$userId = Auth::id();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate expiration timestamp
|
||||
$expiresAt = now()->addMinutes($this->expirationMinutes)->timestamp;
|
||||
$realPendingPath = realpath($pendingUpload->local_path);
|
||||
$realPendingBaseDir = realpath($this->pendingUploadsBaseDirectory());
|
||||
|
||||
// Generate filename with embedded metadata
|
||||
if ($realPendingPath === false || $realPendingBaseDir === false || ! str_starts_with($realPendingPath, $realPendingBaseDir)) {
|
||||
$this->dispatchError('Unauthorized upload path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$isContainer = $target['is_container'];
|
||||
$expiresAt = now()->addMinutes($this->expirationMinutes);
|
||||
$sanitizedFilename = TerminalFileHelper::generateFilename(
|
||||
$originalFilename,
|
||||
$expiresAt,
|
||||
$pendingUpload->original_name,
|
||||
$expiresAt->timestamp,
|
||||
$server->id,
|
||||
$isContainer ? $this->selectedUuid : null
|
||||
$isContainer ? $target['uuid'] : null,
|
||||
);
|
||||
|
||||
$storageDir = "terminal-uploads/user_{$userId}_{$uploadId}";
|
||||
$storageDir = sprintf('terminal-uploads/user_%d_%s', Auth::id(), $pendingUpload->upload_token);
|
||||
$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)) {
|
||||
if (! is_dir($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 (! rename($pendingUpload->local_path, $finalPath)) {
|
||||
$this->dispatchError('Failed to finalize uploaded file. Please try again.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cleanupDirectoryIfEmpty(dirname($pendingUpload->local_path));
|
||||
$this->cleanupDirectoryIfEmpty($this->pendingUploadsBaseDirectory());
|
||||
|
||||
instant_scp($finalPath, $safeServerTmpPath, $server);
|
||||
instant_remote_process([
|
||||
"chmod 600 {$safeServerTmpPath}",
|
||||
], $server);
|
||||
|
||||
$containerPath = null;
|
||||
if ($isContainer) {
|
||||
$containerPath = TerminalFileHelper::generateContainerPath($sanitizedFilename);
|
||||
$safeContainer = escapeshellarg($this->selectedUuid);
|
||||
$safeContainer = escapeshellarg($target['uuid']);
|
||||
$safeContainerPath = escapeshellarg($containerPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker cp {$safeServerTmpPath} {$safeContainer}:{$safeContainerPath}",
|
||||
"docker exec -u 0 {$safeContainer} chmod 600 {$safeContainerPath}",
|
||||
], $server);
|
||||
|
||||
$this->filePath = $containerPath;
|
||||
@@ -345,18 +342,25 @@ class FileImport extends Component
|
||||
$this->filePath = $serverTmpPath;
|
||||
}
|
||||
|
||||
// Schedule cleanup job
|
||||
CleanupExpiredTerminalFilesJob::dispatch(
|
||||
$finalPath,
|
||||
$serverTmpPath,
|
||||
$server->id,
|
||||
$isContainer ? $this->selectedUuid : null,
|
||||
$sanitizedFilename
|
||||
)->delay(now()->addMinutes($this->expirationMinutes));
|
||||
$pendingUpload->forceFill([
|
||||
'server_id' => $server->id,
|
||||
'container_uuid' => $isContainer ? $target['uuid'] : null,
|
||||
'stored_filename' => $sanitizedFilename,
|
||||
'local_path' => $finalPath,
|
||||
'server_path' => $serverTmpPath,
|
||||
'container_path' => $containerPath,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'expires_at' => $expiresAt,
|
||||
'finalized_at' => now(),
|
||||
'last_cleanup_error' => null,
|
||||
])->save();
|
||||
|
||||
$this->pendingUploadUuid = null;
|
||||
$this->hasPendingUpload = false;
|
||||
$this->error = false;
|
||||
$this->dispatch('success', "File ready! Path: {$this->filePath}");
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,16 +369,112 @@ class FileImport extends Component
|
||||
return view('livewire.terminal.file-import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround for Livewire toJSON serialization issue
|
||||
* This prevents "Public method [toJSON] not found" errors
|
||||
*/
|
||||
public function toJSON()
|
||||
private function authorizeTerminalAccess(): void
|
||||
{
|
||||
return json_encode([
|
||||
'selectedUuid' => $this->selectedUuid,
|
||||
'targetName' => $this->targetName,
|
||||
'selectedServerUuid' => $this->selectedServerUuid,
|
||||
]);
|
||||
Gate::authorize('canAccessTerminal');
|
||||
}
|
||||
|
||||
private function pendingUploadsBaseDirectory(): string
|
||||
{
|
||||
return storage_path('app/terminal-uploads-pending/user_'.Auth::id());
|
||||
}
|
||||
|
||||
private function cleanupDirectoryIfEmpty(string $directory): void
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = scandir($directory);
|
||||
if ($contents !== false && count($contents) === 2) {
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearUploadState(bool $resetProgress = true): void
|
||||
{
|
||||
$this->hasPendingUpload = false;
|
||||
$this->pendingUploadUuid = null;
|
||||
$this->filePath = null;
|
||||
$this->isUploading = false;
|
||||
$this->error = false;
|
||||
|
||||
if ($resetProgress) {
|
||||
$this->progress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchError(string $message): void
|
||||
{
|
||||
$this->error = true;
|
||||
$this->isUploading = false;
|
||||
$this->dispatch('error', $message);
|
||||
}
|
||||
|
||||
private function isSelectableTarget(string $uuid): bool
|
||||
{
|
||||
return $this->resolveTargetByUuid($uuid) !== null;
|
||||
}
|
||||
|
||||
private function findPendingUploadForCurrentUser(?string $fileUuid): ?TerminalUploadedFile
|
||||
{
|
||||
if (blank($fileUuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TerminalUploadedFile::query()
|
||||
->ownedByCurrentUserAndTeam()
|
||||
->where('uuid', $fileUuid)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('finalized_at')
|
||||
->where('status', TerminalUploadedFileStatus::Pending)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function findVisibleFileForCurrentUser(string $fileUuid): ?TerminalUploadedFile
|
||||
{
|
||||
return TerminalUploadedFile::query()
|
||||
->ownedByCurrentUserAndTeam()
|
||||
->visible()
|
||||
->where('uuid', $fileUuid)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{uuid:string,server_uuid:string,label:string,is_container:bool}|null
|
||||
*/
|
||||
private function resolveSelectedTarget(): ?array
|
||||
{
|
||||
return $this->resolveTargetByUuid($this->selectedUuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{uuid:string,server_uuid:string,label:string,is_container:bool}|null
|
||||
*/
|
||||
private function resolveTargetByUuid(string $uuid): ?array
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
if (($server['uuid'] ?? null) === $uuid) {
|
||||
return [
|
||||
'uuid' => $uuid,
|
||||
'server_uuid' => $uuid,
|
||||
'label' => sprintf('%s (Server)', $server['name']),
|
||||
'is_container' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->containers as $container) {
|
||||
if (($container['uuid'] ?? null) === $uuid && filled($container['server_uuid'] ?? null)) {
|
||||
return [
|
||||
'uuid' => $uuid,
|
||||
'server_uuid' => $container['server_uuid'],
|
||||
'label' => $container['name'] ?? $uuid,
|
||||
'is_container' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ class Index extends Component
|
||||
|
||||
public bool $showImportModal = false;
|
||||
|
||||
public int $importModalIteration = 0;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->servers = Server::isReachable()->get()->filter(function ($server) {
|
||||
@@ -64,9 +66,9 @@ class Index extends Component
|
||||
public function updatedSelectedUuid()
|
||||
{
|
||||
if ($this->selected_uuid === 'default') {
|
||||
// When cleared to default, do nothing (no error message)
|
||||
return;
|
||||
}
|
||||
|
||||
$this->connectToContainer();
|
||||
}
|
||||
|
||||
@@ -78,62 +80,23 @@ class Index extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid);
|
||||
$this->dispatch('send-terminal-command',
|
||||
$this->dispatch(
|
||||
'send-terminal-command',
|
||||
isset($container),
|
||||
$container['connection_name'] ?? $this->selected_uuid,
|
||||
$container['server_uuid'] ?? $this->selected_uuid
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
public function openImportModal(): void
|
||||
{
|
||||
$this->importModalIteration++;
|
||||
$this->showImportModal = true;
|
||||
}
|
||||
|
||||
public function closeImportModal()
|
||||
public function closeImportModal(): void
|
||||
{
|
||||
$this->showImportModal = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TerminalUploadedFile extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'team_id',
|
||||
'server_id',
|
||||
'upload_token',
|
||||
'container_uuid',
|
||||
'original_name',
|
||||
'stored_filename',
|
||||
'mime_type',
|
||||
'size_bytes',
|
||||
'local_path',
|
||||
'server_path',
|
||||
'container_path',
|
||||
'status',
|
||||
'uploaded_at',
|
||||
'expires_at',
|
||||
'finalized_at',
|
||||
'deleted_at',
|
||||
'cleanup_attempts',
|
||||
'last_cleanup_error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => TerminalUploadedFileStatus::class,
|
||||
'size_bytes' => 'integer',
|
||||
'cleanup_attempts' => 'integer',
|
||||
'uploaded_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'finalized_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function scopeOwnedByCurrentUserAndTeam(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('user_id', auth()->id())
|
||||
->where('team_id', data_get(auth()->user()?->currentTeam(), 'id'));
|
||||
}
|
||||
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('finalized_at')
|
||||
->whereIn('status', [
|
||||
TerminalUploadedFileStatus::Active,
|
||||
TerminalUploadedFileStatus::DeleteFailed,
|
||||
TerminalUploadedFileStatus::Deleting,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopeExpiredForCleanup(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('finalized_at')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->whereIn('status', [
|
||||
TerminalUploadedFileStatus::Active,
|
||||
TerminalUploadedFileStatus::DeleteFailed,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopePendingForCleanup(Builder $query, int $hours): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('finalized_at')
|
||||
->where('uploaded_at', '<=', now()->subHours($hours))
|
||||
->whereIn('status', [
|
||||
TerminalUploadedFileStatus::Pending,
|
||||
TerminalUploadedFileStatus::DeleteFailed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('terminal_uploaded_files', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('server_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('upload_token');
|
||||
$table->string('container_uuid')->nullable();
|
||||
$table->string('original_name');
|
||||
$table->string('stored_filename');
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedBigInteger('size_bytes')->default(0);
|
||||
$table->text('local_path');
|
||||
$table->text('server_path')->nullable();
|
||||
$table->text('container_path')->nullable();
|
||||
$table->string('status')->default('pending');
|
||||
$table->timestamp('uploaded_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('finalized_at')->nullable();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
$table->unsignedInteger('cleanup_attempts')->default(0);
|
||||
$table->text('last_cleanup_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'team_id', 'upload_token']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
$table->index(['user_id', 'team_id', 'status']);
|
||||
$table->index(['server_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('terminal_uploaded_files');
|
||||
}
|
||||
};
|
||||
@@ -93,6 +93,11 @@
|
||||
]) }}">
|
||||
Terminal
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('server.upload') ? 'dark:text-white' : '' }}" href="{{ route('server.upload', [
|
||||
'server_uuid' => data_get($server, 'uuid'),
|
||||
]) }}" {{ wireNavigate() }}>
|
||||
Upload
|
||||
</a>
|
||||
@endcan
|
||||
@can('update', $server)
|
||||
<a class="{{ request()->routeIs('server.security.patches') ? 'dark:text-white' : '' }}" href="{{ route('server.security.patches', [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > Upload | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2>Upload</h2>
|
||||
<div>Upload a file to this server or one of its running containers.</div>
|
||||
</div>
|
||||
|
||||
@if (! $server->isTerminalEnabled())
|
||||
<div>Terminal access is disabled on this server.</div>
|
||||
@elseif (! $server->isFunctional())
|
||||
<div>Server is not functional. Validate it before uploading files.</div>
|
||||
@else
|
||||
<livewire:terminal.file-import
|
||||
:containers="$containers"
|
||||
:selectedUuid="$server->uuid"
|
||||
:servers="$servers"
|
||||
:key="'server-upload-'.$server->uuid" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,62 +1,186 @@
|
||||
<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
|
||||
x-data="{
|
||||
error: $wire.entangle('error'),
|
||||
filesize: $wire.entangle('filesize'),
|
||||
filename: $wire.entangle('filename'),
|
||||
hasPendingUpload: $wire.entangle('hasPendingUpload'),
|
||||
isUploading: $wire.entangle('isUploading'),
|
||||
progress: $wire.entangle('progress'),
|
||||
filePath: $wire.entangle('filePath'),
|
||||
selectedUuid: $wire.entangle('selectedUuid'),
|
||||
isDragging: false,
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024,
|
||||
uploadUrl: @js(route('upload.terminal')),
|
||||
csrfToken: @js(csrf_token()),
|
||||
async handleFileSelection(event) {
|
||||
const [file] = event.target.files;
|
||||
event.target.value = '';
|
||||
|
||||
<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.
|
||||
if (file) {
|
||||
await this.uploadFile(file);
|
||||
}
|
||||
},
|
||||
async handleDrop(event) {
|
||||
this.isDragging = false;
|
||||
const [file] = event.dataTransfer.files;
|
||||
|
||||
if (file) {
|
||||
await this.uploadFile(file);
|
||||
}
|
||||
},
|
||||
async uploadFile(file) {
|
||||
await $wire.resetPendingUpload();
|
||||
|
||||
this.error = false;
|
||||
this.filePath = null;
|
||||
this.filename = file.name;
|
||||
this.filesize = this.formatFileSize(file.size);
|
||||
this.hasPendingUpload = false;
|
||||
this.isUploading = true;
|
||||
this.progress = 0;
|
||||
|
||||
if (file.size > this.maxFileSize) {
|
||||
this.error = true;
|
||||
this.isUploading = false;
|
||||
await $wire.handleUploadError('File is too large. Maximum size is 10 GB.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadToken = this.generateUploadToken();
|
||||
const totalChunks = Math.max(1, Math.ceil(file.size / this.chunkSize));
|
||||
|
||||
try {
|
||||
for (let index = 0; index < totalChunks; index++) {
|
||||
const start = index * this.chunkSize;
|
||||
const end = Math.min(file.size, start + this.chunkSize);
|
||||
const chunk = file.slice(start, end);
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', chunk, file.name);
|
||||
formData.append('resumableChunkNumber', String(index + 1));
|
||||
formData.append('resumableTotalChunks', String(totalChunks));
|
||||
formData.append('resumableChunkSize', String(this.chunkSize));
|
||||
formData.append('resumableCurrentChunkSize', String(chunk.size));
|
||||
formData.append('resumableTotalSize', String(file.size));
|
||||
formData.append('resumableType', file.type || 'application/octet-stream');
|
||||
formData.append('resumableIdentifier', uploadToken);
|
||||
formData.append('resumableFilename', file.name);
|
||||
formData.append('resumableRelativePath', file.name);
|
||||
|
||||
const response = await fetch(this.uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let data = {};
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (! response.ok || data.error) {
|
||||
throw new Error(data.error || 'File upload failed.');
|
||||
}
|
||||
|
||||
this.progress = typeof data.done === 'number'
|
||||
? Math.round(data.done)
|
||||
: Math.round(((index + 1) / totalChunks) * 100);
|
||||
|
||||
if (data.file_uuid) {
|
||||
await $wire.registerUploadedFile(
|
||||
data.file_uuid,
|
||||
data.original_name || file.name,
|
||||
Number(data.size || file.size),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = true;
|
||||
this.isUploading = false;
|
||||
this.progress = 0;
|
||||
this.hasPendingUpload = false;
|
||||
await $wire.resetPendingUpload();
|
||||
await $wire.handleUploadError(error.message || 'File upload failed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = false;
|
||||
},
|
||||
formatFileSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const precision = size >= 10 || unitIndex === 0 ? 0 : 2;
|
||||
|
||||
return `${Number(size.toFixed(precision))} ${units[unitIndex]}`;
|
||||
},
|
||||
generateUploadToken() {
|
||||
const token = window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
return token.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
},
|
||||
}"
|
||||
@path-copied.window="navigator.clipboard.writeText($event.detail.path)">
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex gap-2 my-3">
|
||||
<div class="w-full">
|
||||
<x-forms.select id="terminal-upload-target" label="Target" wire:model.live="selectedUuid" helper="Choose the server or running container that should receive the uploaded file.">
|
||||
<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
|
||||
</x-forms.select>
|
||||
</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">
|
||||
<div class="min-w-64">
|
||||
<x-forms.select id="expirationMinutes" label="File Expiration" wire:model="expirationMinutes" helper="File will be automatically deleted after this time for security.">
|
||||
@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>
|
||||
<div x-cloak x-show="selectedUuid !== 'default'">
|
||||
<h3 class="pb-2">Upload</h3>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors"
|
||||
:class="isDragging
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-coolgray-300 hover:border-coolgray-400 dark:border-coolgray-400 dark:hover:border-coolgray-300'"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop($event)">
|
||||
<input x-ref="fileUpload" id="file-upload" class="hidden" type="file" @change="handleFileSelection($event)">
|
||||
<button class="w-full cursor-pointer" type="button" @click="$refs.fileUpload.click()">
|
||||
<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" />
|
||||
@@ -69,47 +193,42 @@
|
||||
Any file up to 10GB
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</button>
|
||||
</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>
|
||||
<div x-cloak x-show="isUploading">
|
||||
<div class="pb-1 text-sm">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 x-cloak x-show="filename && !error && !filePath" class="rounded-sm p-4 dark:bg-coolgray-100 mt-4">
|
||||
<h3 class="pb-2">File Uploaded to Temporary Storage</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 class="mt-4 w-full" x-bind:disabled="isUploading || !hasPendingUpload || selectedUuid === 'default'" wire:click="generateFilePath" isHighlighted>
|
||||
Finalize Upload
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
<!-- File Path Result -->
|
||||
<div x-show="filePath" x-cloak class="rounded-sm bg-success/10 border border-success p-4">
|
||||
<div x-cloak x-show="filePath" class="rounded-sm border border-success bg-success/10 p-4 mt-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>
|
||||
<div class="relative mt-1" x-data="{ copied: false }">
|
||||
<code class="block rounded bg-coolgray-100 p-2 pr-12 text-xs dark:bg-coolgray-300" 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" />
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-1.5 text-gray-400 transition-colors hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
type="button">
|
||||
<svg x-show="!copied" class="h-5 w-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">
|
||||
<svg x-show="copied" class="h-5 w-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>
|
||||
@@ -118,20 +237,15 @@
|
||||
<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">
|
||||
<div class="pt-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
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>
|
||||
@@ -149,14 +263,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->uploadedFiles as $file)
|
||||
<tr class="border-b border-coolgray-200 dark:border-coolgray-700">
|
||||
<tr wire:key="terminal-upload-file-{{ $file['uuid'] }}" 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'])
|
||||
@if ($file['container_uuid'])
|
||||
<div class="text-xs text-neutral-500">Container: {{ substr($file['container_uuid'], 0, 12) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
@@ -167,12 +281,12 @@
|
||||
{{ \Carbon\Carbon::createFromTimestamp($file['uploaded_at'])->diffForHumans() }}
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
@if($file['expires_at'])
|
||||
@if ($file['expires_at'])
|
||||
@php
|
||||
$expiresAt = \Carbon\Carbon::createFromTimestamp($file['expires_at']);
|
||||
$now = \Carbon\Carbon::now();
|
||||
@endphp
|
||||
@if($now->gt($expiresAt))
|
||||
@if ($now->gt($expiresAt))
|
||||
<span class="text-red-500">Expired</span>
|
||||
@else
|
||||
<span>{{ $expiresAt->diffForHumans() }}</span>
|
||||
@@ -184,19 +298,32 @@
|
||||
<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">
|
||||
title="Copy remote file path"
|
||||
wire:click="copyPath('{{ $file['uuid'] }}')">
|
||||
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>
|
||||
<x-modal-confirmation
|
||||
title="Confirm Terminal File Deletion?"
|
||||
buttonTitle="Delete"
|
||||
isErrorButton
|
||||
submitAction="confirmDeleteFile"
|
||||
:actions="['The selected uploaded file will be permanently deleted from Coolify and any remote terminal target.']"
|
||||
confirmationText="{{ $file['display_name'] }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the uploaded file name below"
|
||||
shortConfirmationLabel="Uploaded File Name"
|
||||
:confirmWithPassword="false"
|
||||
step2ButtonText="Delete File">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button
|
||||
isError
|
||||
class="cursor-pointer"
|
||||
title="Delete file"
|
||||
x-on:click="$wire.prepareFileDeletion('{{ $file['uuid'] }}')">
|
||||
Delete
|
||||
</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,79 +1,94 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Terminal | Coolify
|
||||
</x-slot>
|
||||
</x-slot>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1>Terminal</h1>
|
||||
<div class="flex gap-2 items-end subtitle">
|
||||
@if ($servers->count() > 0)
|
||||
<x-forms.button wire:click="openImportModal">Import File</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex gap-2 items-end subtitle">
|
||||
<div>Execute commands on your servers and containers without leaving the browser.</div>
|
||||
<x-helper
|
||||
helper="If you're having trouble connecting to your server, make sure that the port is open.<br><br><a class='underline' href='https://coolify.io/docs/knowledge-base/server/firewall/#terminal' target='_blank'>Documentation</a>"></x-helper>
|
||||
</div>
|
||||
<div x-init="$wire.loadContainers()">
|
||||
@if ($isLoadingContainers)
|
||||
<div class="pt-1">
|
||||
<x-loading text="Loading servers and containers..." />
|
||||
</div>
|
||||
<div x-init="$wire.loadContainers()">
|
||||
@if ($isLoadingContainers)
|
||||
<div class="pt-1">
|
||||
<x-loading text="Loading servers and containers..." />
|
||||
</div>
|
||||
@else
|
||||
@if ($servers->count() > 0)
|
||||
<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
|
||||
</x-forms.select>
|
||||
<x-forms.button type="submit">Connect</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
@if ($servers->count() > 0)
|
||||
<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
|
||||
</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
|
||||
<div>No servers with terminal access found.</div>
|
||||
@endif
|
||||
<livewire:project.shared.terminal />
|
||||
</div>
|
||||
@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>
|
||||
@if ($showImportModal)
|
||||
<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 class="flex items-center justify-between py-6 px-6 shrink-0">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold">Import File</h3>
|
||||
<div class="text-sm text-neutral-500">
|
||||
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>
|
||||
<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
|
||||
$initialImportTarget = $selected_uuid !== 'default' ? $selected_uuid : 'default';
|
||||
$importServers = $servers->map(fn ($server) => [
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
])->values()->all();
|
||||
$importContainers = collect($containers)->map(fn ($container) => [
|
||||
'uuid' => $container['uuid'],
|
||||
'name' => $container['name'],
|
||||
'server_uuid' => $container['server_uuid'],
|
||||
])->values()->all();
|
||||
@endphp
|
||||
<livewire:terminal.file-import
|
||||
:containers="$importContainers"
|
||||
:selectedUuid="$initialImportTarget"
|
||||
:servers="$importServers"
|
||||
:key="'file-import-'.$importModalIteration" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
+3
-2
@@ -33,7 +33,6 @@ use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Livewire\Project\Shared\ExecuteContainerCommand;
|
||||
use App\Livewire\Project\Shared\Logs;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
|
||||
use App\Livewire\Project\Show as ProjectShow;
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Livewire\Security\CloudInitScripts;
|
||||
@@ -60,6 +59,7 @@ use App\Livewire\Server\Security\TerminalAccess;
|
||||
use App\Livewire\Server\Sentinel as ServerSentinel;
|
||||
use App\Livewire\Server\Show as ServerShow;
|
||||
use App\Livewire\Server\Swarm as ServerSwarm;
|
||||
use App\Livewire\Server\Upload as ServerUpload;
|
||||
use App\Livewire\Settings\Advanced as SettingsAdvanced;
|
||||
use App\Livewire\Settings\Index as SettingsIndex;
|
||||
use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs;
|
||||
@@ -291,6 +291,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
|
||||
Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs');
|
||||
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
|
||||
Route::get('/upload', ServerUpload::class)->name('server.upload')->middleware('can.access.terminal');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
|
||||
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
|
||||
Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource');
|
||||
@@ -323,7 +324,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::post('/upload/terminal', [UploadController::class, 'uploadTerminalFile'])->middleware('can.access.terminal')->name('upload.terminal');
|
||||
Route::get('/download/backup/{executionId}', function () {
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Models\Team;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('cleans expired and stale pending terminal uploads from the database and filesystem', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$expiredDirectory = storage_path('app/terminal-uploads/user_'.$user->id.'_expired-token');
|
||||
$pendingDirectory = storage_path('app/terminal-uploads-pending/user_'.$user->id.'/pending-token');
|
||||
|
||||
mkdir($expiredDirectory, 0755, true);
|
||||
mkdir($pendingDirectory, 0755, true);
|
||||
|
||||
$expiredPath = $expiredDirectory.'/expired-file.txt';
|
||||
$pendingPath = $pendingDirectory.'/pending-file.txt';
|
||||
|
||||
file_put_contents($expiredPath, 'expired');
|
||||
file_put_contents($pendingPath, 'pending');
|
||||
|
||||
$expiredUpload = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'team_id' => $team->id,
|
||||
'upload_token' => 'expired-token',
|
||||
'original_name' => 'expired.txt',
|
||||
'stored_filename' => 'expired-file.txt',
|
||||
'mime_type' => 'text-plain',
|
||||
'size_bytes' => 7,
|
||||
'local_path' => $expiredPath,
|
||||
'server_path' => null,
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now()->subHours(2),
|
||||
'expires_at' => now()->subMinute(),
|
||||
'finalized_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$pendingUpload = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'team_id' => $team->id,
|
||||
'upload_token' => 'pending-token',
|
||||
'original_name' => 'pending.txt',
|
||||
'stored_filename' => 'pending-file.txt',
|
||||
'mime_type' => 'text-plain',
|
||||
'size_bytes' => 7,
|
||||
'local_path' => $pendingPath,
|
||||
'server_path' => null,
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Pending,
|
||||
'uploaded_at' => now()->subHours(4),
|
||||
'expires_at' => null,
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('cleanup:terminal-uploads', ['--pending-hours' => 1])
|
||||
->expectsOutput('Cleaned 1 expired terminal uploads.')
|
||||
->expectsOutput('Cleaned 1 stale pending terminal uploads.')
|
||||
->assertSuccessful();
|
||||
|
||||
$expiredUpload->refresh();
|
||||
$pendingUpload->refresh();
|
||||
|
||||
expect(file_exists($expiredPath))->toBeFalse()
|
||||
->and(file_exists($pendingPath))->toBeFalse()
|
||||
->and($expiredUpload->status)->toBe(TerminalUploadedFileStatus::Deleted)
|
||||
->and($pendingUpload->status)->toBe(TerminalUploadedFileStatus::Deleted)
|
||||
->and($expiredUpload->deleted_at)->not->toBeNull()
|
||||
->and($pendingUpload->deleted_at)->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Upload as ServerUpload;
|
||||
use App\Livewire\Terminal\FileImport;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->team = Team::factory()->create([
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'private_key' => <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'name' => 'Upload Target Server',
|
||||
'ip' => 'coolify-upload-page-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$this->server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_terminal_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the upload link on the server page for owners with terminal access', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.show', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Upload');
|
||||
$response->assertSee(route('server.upload', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]), false);
|
||||
});
|
||||
|
||||
it('renders the dedicated server upload page with the terminal upload component preselected to the current server', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.upload', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSeeLivewire(ServerUpload::class);
|
||||
$response->assertSeeLivewire(FileImport::class);
|
||||
$response->assertSee('Upload a file to this server or one of its running containers.');
|
||||
$response->assertSee('Upload Target Server');
|
||||
|
||||
Livewire::test(ServerUpload::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSet('server.uuid', $this->server->uuid)
|
||||
->assertSet('servers.0.uuid', $this->server->uuid);
|
||||
});
|
||||
|
||||
it('forbids server upload pages for team members without terminal access', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.upload', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]))->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Livewire\Terminal\FileImport;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create(['id' => 0]);
|
||||
});
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('rejects copying terminal upload paths outside the current team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherOwner->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $otherOwner->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
'upload_token' => 'foreign-upload-token',
|
||||
'original_name' => 'foreign.txt',
|
||||
'stored_filename' => 'stored-foreign.txt',
|
||||
'mime_type' => 'text-plain',
|
||||
'size_bytes' => 128,
|
||||
'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'),
|
||||
'server_path' => '/tmp/stored-foreign.txt',
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => now()->addHour(),
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->call('copyPath', $terminalUploadedFile->uuid)
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
it('rejects preparing terminal upload deletions outside the current team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherOwner->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $otherOwner->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
'upload_token' => 'foreign-prepare-delete-token',
|
||||
'original_name' => 'foreign.txt',
|
||||
'stored_filename' => 'stored-foreign.txt',
|
||||
'mime_type' => 'text-plain',
|
||||
'size_bytes' => 128,
|
||||
'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'),
|
||||
'server_path' => '/tmp/stored-foreign.txt',
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => now()->addHour(),
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->call('prepareFileDeletion', $terminalUploadedFile->uuid)
|
||||
->assertSet('pendingDeleteFileUuid', null)
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
it('rejects deleting terminal uploads outside the current team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherOwner->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $otherOwner->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
'upload_token' => 'foreign-delete-token',
|
||||
'original_name' => 'foreign.txt',
|
||||
'stored_filename' => 'stored-foreign.txt',
|
||||
'mime_type' => 'text-plain',
|
||||
'size_bytes' => 128,
|
||||
'local_path' => storage_path('app/terminal-uploads/user_999_foreign/stored-foreign.txt'),
|
||||
'server_path' => '/tmp/stored-foreign.txt',
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => now()->addHour(),
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->call('deleteFile', $terminalUploadedFile->uuid)
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
it('requires a visible terminal upload before confirming deletion', function () {
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->call('confirmDeleteFile')
|
||||
->assertSet('pendingDeleteFileUuid', null)
|
||||
->assertDispatched('error', 'File not found.');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Terminal\DeleteTerminalUploadedFile;
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Livewire\Terminal\FileImport;
|
||||
use App\Livewire\Terminal\Index as TerminalIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create(['id' => 0]);
|
||||
});
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('shows the import file button even when no terminal target is selected', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'name' => 'Terminal Server',
|
||||
'ip' => 'coolify-terminal-test-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_terminal_enabled' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(TerminalIndex::class)
|
||||
->call('loadContainers')
|
||||
->assertSet('selected_uuid', 'default')
|
||||
->assertSee('Import File');
|
||||
});
|
||||
|
||||
it('requires selecting a server or container before finalizing an uploaded file', function () {
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'default',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server One'],
|
||||
],
|
||||
'containers' => [
|
||||
['uuid' => 'container-uuid', 'name' => 'App Container', 'server_uuid' => 'server-uuid'],
|
||||
],
|
||||
])
|
||||
->set('hasPendingUpload', true)
|
||||
->set('filename', 'dump.sql')
|
||||
->call('generateFilePath')
|
||||
->assertDispatched('error', 'Please select a server or container.');
|
||||
});
|
||||
|
||||
it('renders the shared modal confirmation for deleting uploaded files', function () {
|
||||
TerminalUploadedFile::query()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'team_id' => $this->team->id,
|
||||
'upload_token' => 'render-delete-modal-token',
|
||||
'original_name' => 'database-dump.sql',
|
||||
'stored_filename' => 'stored-database-dump.sql',
|
||||
'mime_type' => 'application-sql',
|
||||
'size_bytes' => 1024,
|
||||
'local_path' => storage_path('app/terminal-uploads/user_1_render-delete-modal/stored-database-dump.sql'),
|
||||
'server_path' => '/tmp/stored-database-dump.sql',
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => now()->addHour(),
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server One'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->assertSee('Confirm Terminal File Deletion?')
|
||||
->assertSee('Please confirm the deletion by entering the uploaded file name below');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('confirmDeleteFile')
|
||||
->not->toContain('wire:confirm=');
|
||||
});
|
||||
|
||||
it('prepares and confirms terminal upload deletion through the shared modal flow', function () {
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'team_id' => $this->team->id,
|
||||
'upload_token' => 'confirm-delete-modal-token',
|
||||
'original_name' => 'database-dump.sql',
|
||||
'stored_filename' => 'stored-database-dump.sql',
|
||||
'mime_type' => 'application-sql',
|
||||
'size_bytes' => 1024,
|
||||
'local_path' => storage_path('app/terminal-uploads/user_1_confirm-delete-modal/stored-database-dump.sql'),
|
||||
'server_path' => '/tmp/stored-database-dump.sql',
|
||||
'container_path' => null,
|
||||
'status' => TerminalUploadedFileStatus::Active,
|
||||
'uploaded_at' => now(),
|
||||
'expires_at' => now()->addHour(),
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
$deleteTerminalUploadedFile = Mockery::mock(DeleteTerminalUploadedFile::class);
|
||||
$deleteTerminalUploadedFile->shouldReceive('__invoke')
|
||||
->once()
|
||||
->withArgs(function (TerminalUploadedFile $file) use ($terminalUploadedFile): bool {
|
||||
return $file->is($terminalUploadedFile);
|
||||
});
|
||||
|
||||
app()->instance(DeleteTerminalUploadedFile::class, $deleteTerminalUploadedFile);
|
||||
|
||||
Livewire::test(FileImport::class, [
|
||||
'selectedUuid' => 'server-uuid',
|
||||
'servers' => [
|
||||
['uuid' => 'server-uuid', 'name' => 'Server One'],
|
||||
],
|
||||
'containers' => [],
|
||||
])
|
||||
->call('prepareFileDeletion', $terminalUploadedFile->uuid)
|
||||
->assertSet('pendingDeleteFileUuid', $terminalUploadedFile->uuid)
|
||||
->call('confirmDeleteFile')
|
||||
->assertSet('pendingDeleteFileUuid', null)
|
||||
->assertDispatched('success', 'File deleted successfully!');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\TerminalUploadedFileStatus;
|
||||
use App\Models\Team;
|
||||
use App\Models\TerminalUploadedFile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
it('denies unauthenticated terminal uploads', function () {
|
||||
$this->postJson(route('upload.terminal'))
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
it('denies terminal uploads for team members without terminal access', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->post(route('upload.terminal'), [
|
||||
'file' => UploadedFile::fake()->create('notes.txt', 1),
|
||||
], [
|
||||
'Accept' => 'application/json',
|
||||
])->assertForbidden();
|
||||
});
|
||||
|
||||
it('stores a pending terminal upload record for team owners', function () {
|
||||
$owner = User::factory()->create();
|
||||
$owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$file = UploadedFile::fake()->create('terminal-import.sql', 32);
|
||||
|
||||
$response = $this->post(route('upload.terminal'), [
|
||||
'file' => $file,
|
||||
'resumableChunkNumber' => 1,
|
||||
'resumableTotalChunks' => 1,
|
||||
'resumableChunkSize' => $file->getSize(),
|
||||
'resumableCurrentChunkSize' => $file->getSize(),
|
||||
'resumableTotalSize' => $file->getSize(),
|
||||
'resumableType' => $file->getMimeType(),
|
||||
'resumableIdentifier' => 'terminal_upload_token',
|
||||
'resumableFilename' => 'terminal-import.sql',
|
||||
'resumableRelativePath' => 'terminal-import.sql',
|
||||
], [
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'done' => 100,
|
||||
'status' => true,
|
||||
'upload_token' => 'terminal_upload_token',
|
||||
'original_name' => 'terminal-import.sql',
|
||||
]);
|
||||
|
||||
$storedFilename = $response->json('stored_filename');
|
||||
$fileUuid = $response->json('file_uuid');
|
||||
|
||||
expect($storedFilename)
|
||||
->not->toBeEmpty()
|
||||
->and($storedFilename)->toEndWith('.sql');
|
||||
|
||||
$terminalUploadedFile = TerminalUploadedFile::query()->where('uuid', $fileUuid)->first();
|
||||
|
||||
expect($terminalUploadedFile)
|
||||
->not->toBeNull()
|
||||
->and($terminalUploadedFile->user_id)->toBe($owner->id)
|
||||
->and($terminalUploadedFile->team_id)->toBe($this->team->id)
|
||||
->and($terminalUploadedFile->upload_token)->toBe('terminal_upload_token')
|
||||
->and($terminalUploadedFile->status)->toBe(TerminalUploadedFileStatus::Pending)
|
||||
->and($terminalUploadedFile->original_name)->toBe('terminal-import.sql')
|
||||
->and(file_exists($terminalUploadedFile->local_path))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\TerminalFileHelper;
|
||||
|
||||
it('generates and parses terminal filenames with alphanumeric extensions', function () {
|
||||
$filename = TerminalFileHelper::generateFilename('backup.dump.sql', time() + 3600, 42, 'container-uuid-1234');
|
||||
|
||||
$metadata = TerminalFileHelper::parseFilename($filename);
|
||||
|
||||
expect($metadata)
|
||||
->not->toBeNull()
|
||||
->and($metadata['server_id'])->toBe(42)
|
||||
->and($metadata['extension'])->toBe('sql')
|
||||
->and($metadata['container_uuid'])->toBe('container-uu');
|
||||
});
|
||||
|
||||
it('rejects malformed terminal filename extensions', function () {
|
||||
$filename = '1700000000_1700003600_42_nocontainer_demo_a1b2c3d4.bad-ext';
|
||||
|
||||
expect(TerminalFileHelper::parseFilename($filename))->toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user