From 1dbbcf7bee234663f5da0178ce9861576b0108c4 Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 18 Nov 2025 10:34:33 +0000 Subject: [PATCH 1/5] Add File Browser Service --- app/api/projects/route.ts | 38 ++++- components/terminal/terminal-container.tsx | 4 +- components/terminal/terminal-toolbar.tsx | 117 +------------ lib/const.ts | 1 + lib/events/sandbox/sandboxListener.ts | 9 +- lib/k8s/sandbox-manager.ts | 181 ++++++++++++++++++++- lib/repo/sandbox.ts | 5 +- prisma/schema.prisma | 6 +- 8 files changed, 231 insertions(+), 130 deletions(-) diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index d54ffeb..a97a69c 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -117,7 +117,7 @@ export const GET = withAuth(async (req, _context, session) } } catch { // If user doesn't have kubeconfig configured, log warning but don't fail - // Return empty array instead of filtering by namespace + // Skip namespace filtering and return all projects for the user logger.warn( `User ${session.user.id} does not have KUBECONFIG configured, returning all projects` ) @@ -194,6 +194,8 @@ export const POST = withAuth(async (req, _context, session) const k8sProjectName = KubernetesUtils.toK8sProjectName(name) const randomSuffix = KubernetesUtils.generateRandomString() const ttydAuthToken = generateRandomString() + const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username + const fileBrowserPassword = generateRandomString(16) // 16 char random password const databaseName = `${k8sProjectName}-${randomSuffix}` const sandboxName = `${k8sProjectName}-${randomSuffix}` @@ -246,7 +248,7 @@ export const POST = withAuth(async (req, _context, session) }) // 4. Create Environment record for ttyd access token - const environment = await tx.environment.create({ + const ttydEnv = await tx.environment.create({ data: { projectId: project.id, key: 'TTYD_ACCESS_TOKEN', @@ -256,11 +258,39 @@ export const POST = withAuth(async (req, _context, session) }, }) - return { project, database, sandbox, environment } + // 5. Create Environment records for filebrowser credentials + const fileBrowserUsernameEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_USERNAME', + value: fileBrowserUsername, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: false, + }, + }) + + const fileBrowserPasswordEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_PASSWORD', + value: fileBrowserPassword, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: true, // Mark as secret since it's a password + }, + }) + + return { + project, + database, + sandbox, + ttydEnv, + fileBrowserUsernameEnv, + fileBrowserPasswordEnv, + } }) logger.info( - `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, and environment: ${result.environment.id}` + `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` ) return NextResponse.json(result.project) diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index 72aed35..fec47a5 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -120,7 +120,7 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) @@ -128,4 +128,4 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) ); -} \ No newline at end of file +} diff --git a/components/terminal/terminal-toolbar.tsx b/components/terminal/terminal-toolbar.tsx index 7924620..1604bba 100644 --- a/components/terminal/terminal-toolbar.tsx +++ b/components/terminal/terminal-toolbar.tsx @@ -8,28 +8,8 @@ import { useState } from 'react'; import type { Prisma } from '@prisma/client'; -import { - // ChevronDown, - // Loader2, - Network, - // Play, - Plus, - // Square, - Terminal as TerminalIcon, - // Trash2, - X, -} from 'lucide-react'; +import { Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, @@ -37,15 +17,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -// import { -// DropdownMenu, -// DropdownMenuContent, -// DropdownMenuItem, -// DropdownMenuSeparator, -// DropdownMenuTrigger, -// } from '@/components/ui/dropdown-menu'; -import { useProjectOperations } from '@/hooks/use-project-operations'; -import { getAvailableProjectActions } from '@/lib/util/action'; import { getStatusBgClasses } from '@/lib/util/status-colors'; import { cn } from '@/lib/utils'; @@ -93,21 +64,6 @@ export function TerminalToolbar({ onTabAdd, }: TerminalToolbarProps) { const [showNetworkDialog, setShowNetworkDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const { executeOperation, loading } = useProjectOperations(project.id); - - const availableActions = getAvailableProjectActions(project); - - const handleDeleteClick = () => { - setShowDeleteDialog(true); - }; - - const handleDeleteConfirm = () => { - setShowDeleteDialog(false); - executeOperation('DELETE'); - }; - const networkEndpoints = [ { domain: sandbox?.publicUrl || '', port: 3000, protocol: 'HTTPS', label: 'Application' }, { domain: sandbox?.ttydUrl || '', port: 7681, protocol: 'HTTPS', label: 'Terminal' }, @@ -170,53 +126,6 @@ export function TerminalToolbar({ Network - - {/* Operations Dropdown */} - {/* TODO: delete after Nov 18 */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* {availableActions.includes('START') && (*/} - {/* executeOperation('START')}*/} - {/* disabled={loading !== null}*/} - {/* className="text-xs cursor-pointer focus:bg-[#37373d] focus:text-white"*/} - {/* >*/} - {/* {loading === 'START' ? (*/} - {/* <>*/} - {/* */} - {/* Starting...*/} - {/* */} - {/* ) : (*/} - {/* <>*/} - {/* */} - {/* Start*/} - {/* */} - {/* )}*/} - {/* */} - {/* )}*/} - {/* {availableActions.includes('STOP') && (*/} - {/* executeOperation('STOP')}*/} - {/* disabled={loading !== null}*/} - {/* className="text-xs cursor-pointer focus:bg-[#37373d] focus:text-white"*/} - {/* >*/} - {/* {loading === 'STOP' ? (*/} - {/* <>*/} - {/* */} - {/* Stopping...*/} - {/* */} - {/* ) : (*/} - {/* <>*/} - {/* */} - {/* Stop*/} @@ -257,30 +166,6 @@ export function TerminalToolbar({ - - {/* Delete Confirmation Dialog */} - - - - Delete Project - - Are you sure you want to delete this project? This will terminate all resources - (databases, sandboxes) and cannot be undone. - - - - - Cancel - - - Delete - - - - ); } diff --git a/lib/const.ts b/lib/const.ts index 70526be..41a8d9f 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -2,6 +2,7 @@ export enum EnvironmentCategory { AUTH = 'auth', PAYMENT = 'payment', TTYD = 'ttyd', + FILE_BROWSER = 'file_browser', GENERAL = 'general', SECRET = 'secret', } diff --git a/lib/events/sandbox/sandboxListener.ts b/lib/events/sandbox/sandboxListener.ts index 8519994..b456f9d 100644 --- a/lib/events/sandbox/sandboxListener.ts +++ b/lib/events/sandbox/sandboxListener.ts @@ -52,11 +52,16 @@ async function handleCreateSandbox(payload: SandboxEventPayload): Promise ) logger.info( - `Sandbox ${sandbox.id} created in Kubernetes: ${sandboxInfo.publicUrl}, ${sandboxInfo.ttydUrl}` + `Sandbox ${sandbox.id} created in Kubernetes: ${sandboxInfo.publicUrl}, ${sandboxInfo.ttydUrl}, ${sandboxInfo.fileBrowserUrl}` ) // Update sandbox with URLs - await updateSandboxUrls(sandbox.id, sandboxInfo.publicUrl, sandboxInfo.ttydUrl) + await updateSandboxUrls( + sandbox.id, + sandboxInfo.publicUrl, + sandboxInfo.ttydUrl, + sandboxInfo.fileBrowserUrl + ) // Change status to STARTING await updateSandboxStatus(sandbox.id, 'STARTING') diff --git a/lib/k8s/sandbox-manager.ts b/lib/k8s/sandbox-manager.ts index 90fe321..ef31e30 100644 --- a/lib/k8s/sandbox-manager.ts +++ b/lib/k8s/sandbox-manager.ts @@ -20,6 +20,8 @@ export interface SandboxInfo { publicUrl: string /** Terminal access URL */ ttydUrl: string + /** File browser access URL */ + fileBrowserUrl: string } /** @@ -127,11 +129,15 @@ export class SandboxManager { const ttydAccessToken = envVars['TTYD_ACCESS_TOKEN'] const ttydUrl = ttydAccessToken ? `${baseTtydUrl}?arg=${ttydAccessToken}` : baseTtydUrl + // Build fileBrowserUrl (no token in URL, uses standard login) + const fileBrowserUrl = `https://${sandboxName}-filebrowser.${ingressDomain}` + return { statefulSetName: sandboxName, serviceName: serviceName, publicUrl: `https://${sandboxName}-app.${ingressDomain}`, ttydUrl: ttydUrl, + fileBrowserUrl: fileBrowserUrl, } } @@ -543,6 +549,13 @@ export class SandboxManager { return `${sandboxName}-ttyd-ingress` } + /** + * Get FileBrowser Ingress name + */ + private getFileBrowserIngressName(sandboxName: string): string { + return `${sandboxName}-filebrowser-ingress` + } + /** * Find StatefulSet by exact match * @@ -691,8 +704,91 @@ export class SandboxManager { }, ], }, + { + name: 'filebrowser', + image: 'filebrowser/filebrowser:latest', + command: ['/bin/sh', '-c'], + args: [ + ` +set -e + +echo "=== FileBrowser Initialization ===" + +# Only initialize if database doesn't exist +if [ ! -f /database/filebrowser.db ]; then + echo "→ Database not found, initializing..." + + # Initialize config and database + filebrowser config init \ + --database /database/filebrowser.db \ + --root /srv \ + --address 0.0.0.0 \ + --port 8080 + + echo "✓ Config initialized" + + # Add user with plaintext password (filebrowser will hash it) + filebrowser users add "$FILE_BROWSER_USERNAME" "$FILE_BROWSER_PASSWORD" \ + --database /database/filebrowser.db \ + --perm.admin + + echo "✓ User created: $FILE_BROWSER_USERNAME" +else + echo "✓ Database already exists, skipping initialization" +fi + +echo "→ Starting FileBrowser..." +# Start filebrowser +exec filebrowser --database /database/filebrowser.db + `.trim(), + ], + env: [ + { + name: 'FILE_BROWSER_USERNAME', + value: containerEnv['FILE_BROWSER_USERNAME'] || 'admin', + }, + { + name: 'FILE_BROWSER_PASSWORD', + value: containerEnv['FILE_BROWSER_PASSWORD'] || 'admin', + }, + ], + ports: [{ containerPort: 8080, name: 'port-8080' }], + resources: { + requests: { + cpu: '50m', + memory: '64Mi', + }, + limits: { + cpu: '500m', + memory: '256Mi', + }, + }, + volumeMounts: [ + { + name: 'vn-homevn-agent', + mountPath: '/srv', + }, + { + name: 'filebrowser-database', + mountPath: '/database', + }, + { + name: 'filebrowser-config', + mountPath: '/config', + }, + ], + }, + ], + volumes: [ + { + name: 'filebrowser-database', + emptyDir: {}, + }, + { + name: 'filebrowser-config', + emptyDir: {}, + }, ], - volumes: [], }, }, volumeClaimTemplates: [ @@ -908,6 +1004,7 @@ echo "=== Init Container: Completed successfully ===" ports: [ { port: 3000, targetPort: 3000, name: 'port-3000', protocol: 'TCP' }, { port: 7681, targetPort: 7681, name: 'port-7681', protocol: 'TCP' }, + { port: 8080, targetPort: 8080, name: 'port-8080', protocol: 'TCP' }, ], selector: { app: sandboxName, @@ -919,7 +1016,7 @@ echo "=== Init Container: Completed successfully ===" } /** - * Create Ingresses (App and Ttyd) - idempotent + * Create Ingresses (App, Ttyd, and FileBrowser) - idempotent */ private async createIngresses( sandboxName: string, @@ -930,6 +1027,7 @@ echo "=== Init Container: Completed successfully ===" ): Promise { const appIngressName = this.getAppIngressName(sandboxName) const ttydIngressName = this.getTtydIngressName(sandboxName) + const fileBrowserIngressName = this.getFileBrowserIngressName(sandboxName) const appIngress = this.createAppIngress( sandboxName, @@ -945,10 +1043,18 @@ echo "=== Init Container: Completed successfully ===" serviceName, ingressDomain ) + const fileBrowserIngress = this.createFileBrowserIngress( + sandboxName, + k8sProjectName, + namespace, + serviceName, + ingressDomain + ) await Promise.all([ this.createIngressIfNotExists(appIngressName, namespace, appIngress), this.createIngressIfNotExists(ttydIngressName, namespace, ttydIngress), + this.createIngressIfNotExists(fileBrowserIngressName, namespace, fileBrowserIngress), ]) } @@ -1106,6 +1212,73 @@ echo "=== Init Container: Completed successfully ===" } } + /** + * Create FileBrowser Ingress + */ + private createFileBrowserIngress( + sandboxName: string, + k8sProjectName: string, + namespace: string, + serviceName: string, + ingressDomain: string + ): k8s.V1Ingress { + const ingressName = this.getFileBrowserIngressName(sandboxName) + const host = `${sandboxName}-filebrowser.${ingressDomain}` + + return { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: ingressName, + namespace, + labels: { + 'cloud.sealos.io/app-deploy-manager': sandboxName, + 'cloud.sealos.io/app-deploy-manager-domain': `${sandboxName}-filebrowser`, + 'project.fullstackagent.io/name': k8sProjectName, + }, + annotations: { + 'kubernetes.io/ingress.class': 'nginx', + 'nginx.ingress.kubernetes.io/proxy-body-size': '32m', + 'nginx.ingress.kubernetes.io/ssl-redirect': 'false', + 'nginx.ingress.kubernetes.io/backend-protocol': 'HTTP', + 'nginx.ingress.kubernetes.io/client-body-buffer-size': '64k', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '64k', + 'nginx.ingress.kubernetes.io/proxy-send-timeout': '300', + 'nginx.ingress.kubernetes.io/proxy-read-timeout': '300', + 'nginx.ingress.kubernetes.io/server-snippet': + 'client_header_buffer_size 64k;\nlarge_client_header_buffers 4 128k;', + }, + }, + spec: { + rules: [ + { + host, + http: { + paths: [ + { + pathType: 'Prefix', + path: '/', + backend: { + service: { + name: serviceName, + port: { number: 8080 }, + }, + }, + }, + ], + }, + }, + ], + tls: [ + { + hosts: [host], + secretName: 'wildcard-cert', + }, + ], + }, + } + } + /** * Delete StatefulSet (exact deletion) */ @@ -1150,15 +1323,17 @@ echo "=== Init Container: Completed successfully ===" } /** - * Delete Ingresses (exact deletion of App and Ttyd Ingress) + * Delete Ingresses (exact deletion of App, Ttyd, and FileBrowser Ingress) */ private async deleteIngresses(sandboxName: string, namespace: string): Promise { const appIngressName = this.getAppIngressName(sandboxName) const ttydIngressName = this.getTtydIngressName(sandboxName) + const fileBrowserIngressName = this.getFileBrowserIngressName(sandboxName) await Promise.all([ this.deleteIngress(appIngressName, namespace), this.deleteIngress(ttydIngressName, namespace), + this.deleteIngress(fileBrowserIngressName, namespace), ]) } diff --git a/lib/repo/sandbox.ts b/lib/repo/sandbox.ts index 2c7cddd..ebcaf76 100644 --- a/lib/repo/sandbox.ts +++ b/lib/repo/sandbox.ts @@ -181,12 +181,14 @@ export async function updateSandboxStatus( * @param sandboxId - Sandbox ID * @param publicUrl - Public application URL * @param ttydUrl - Terminal URL + * @param fileBrowserUrl - File browser URL * @returns Updated sandbox */ export async function updateSandboxUrls( sandboxId: string, publicUrl: string, - ttydUrl: string + ttydUrl: string, + fileBrowserUrl: string ): Promise { logger.info(`Updating sandbox ${sandboxId} URLs`) @@ -195,6 +197,7 @@ export async function updateSandboxUrls( data: { publicUrl, ttydUrl, + fileBrowserUrl, updatedAt: new Date(), }, }) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81ff46e..684480d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,13 +161,15 @@ model Sandbox { sandboxName String // Ingress URLs - publicUrl String? // App ingress (port 3000) - ttydUrl String? // Terminal ingress (port 7681) + publicUrl String? // App ingress (port 3000) + ttydUrl String? // Terminal ingress (port 7681) + fileBrowserUrl String? // File browser ingress (port 8080) // Resource status (managed independently) status ResourceStatus @default(CREATING) // Optimistic locking to prevent concurrent updates + // Reconcile job uses this to lock the sandbox and prevent concurrent updates lockedUntil DateTime? // Locked until this timestamp, null means unlocked // Runtime configuration From fa4fdf53ecd19024fbac2cd1c7cc7c7cae684cd2 Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 18 Nov 2025 15:11:34 +0000 Subject: [PATCH 2/5] feat(web): xterm supports file uploads --- app/test-upload/page.tsx | 267 ++++++++++++++ components/terminal/hooks/use-file-drop.ts | 249 +++++++++++++ components/terminal/hooks/use-file-upload.tsx | 235 ++++++++++++ components/terminal/terminal-container.tsx | 21 ++ components/terminal/terminal-display.tsx | 16 +- components/terminal/terminal-toolbar.tsx | 90 ++++- components/terminal/xterm-terminal.tsx | 160 ++++++++- docs/fixes/paste-event-capture-fix.md | 331 +++++++++++++++++ lib/k8s/sandbox-manager.ts | 11 + lib/utils/filebrowser.ts | 333 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 134 +++++++ public/{icon.svg => favicon.svg} | 0 13 files changed, 1827 insertions(+), 21 deletions(-) create mode 100644 app/test-upload/page.tsx create mode 100644 components/terminal/hooks/use-file-drop.ts create mode 100644 components/terminal/hooks/use-file-upload.tsx create mode 100644 docs/fixes/paste-event-capture-fix.md create mode 100644 lib/utils/filebrowser.ts rename public/{icon.svg => favicon.svg} (100%) diff --git a/app/test-upload/page.tsx b/app/test-upload/page.tsx new file mode 100644 index 0000000..21f0aa4 --- /dev/null +++ b/app/test-upload/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState } from 'react'; + +/** + * FileBrowser Upload Test Page + * + * Test page to debug file upload and authentication issues + */ +export default function TestUploadPage() { + const [result, setResult] = useState(''); + const [loading, setLoading] = useState(false); + + // Test configuration + const [config, setConfig] = useState({ + url: 'https://dd-xeuqsjxc-filebrowser.usw.sealos.io', + username: 'admin', + password: 'admin', + path: '/', + }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setLoading(true); + setResult('Starting upload with JWT...\n'); + + try { + // Step 1: Login to get JWT token + setResult(prev => prev + `\n1. Logging in...\n`); + + const loginResponse = await fetch(`${config.url}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: config.username, + password: config.password, + recaptcha: '', + }), + }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + setResult(prev => prev + ` ❌ Login failed: ${errorText}\n`); + return; + } + + const token = await loginResponse.text(); + setResult(prev => prev + ` ✅ Got JWT token\n`); + + // Step 2: Try TUS upload (correct method) + setResult(prev => prev + `\n2. Uploading file via TUS...\n`); + setResult(prev => prev + ` File: ${file.name} (${file.size} bytes)\n`); + + // TUS: Create upload + const createUrl = `${config.url}/api/tus/${encodeURIComponent(file.name)}?override=false`; + setResult(prev => prev + ` Creating: ${createUrl}\n`); + + const createResponse = await fetch(createUrl, { + method: 'POST', + headers: { + 'X-Auth': token, + 'Upload-Length': file.size.toString(), + 'Tus-Resumable': '1.0.0', + }, + }); + + setResult(prev => prev + ` Create status: ${createResponse.status} ${createResponse.statusText}\n`); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + setResult(prev => prev + ` ❌ Create failed: ${errorText}\n`); + return; + } + + // TUS: Upload file content + const uploadUrl = `${config.url}/api/tus/${encodeURIComponent(file.name)}`; + setResult(prev => prev + `\n3. Uploading content...\n`); + + const uploadResponse = await fetch(uploadUrl, { + method: 'PATCH', + headers: { + 'X-Auth': token, + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'Tus-Resumable': '1.0.0', + }, + body: file, + }); + + setResult(prev => prev + ` Upload status: ${uploadResponse.status} ${uploadResponse.statusText}\n`); + + if (uploadResponse.ok) { + setResult(prev => prev + ` ✅ Upload successful!\n`); + setResult(prev => prev + `\n4. Verifying file exists...\n`); + + // Verify file exists + const verifyResponse = await fetch(`${config.url}/api/resources${config.path}`, { + method: 'GET', + headers: { + 'X-Auth': token, + }, + }); + + if (verifyResponse.ok) { + const data = await verifyResponse.json(); + const uploaded = data.items?.find((item: any) => item.name === file.name); + if (uploaded) { + setResult(prev => prev + ` ✅ File verified: ${uploaded.path}\n`); + } else { + setResult(prev => prev + ` ⚠️ File not found in listing\n`); + } + } + } else { + const errorText = await uploadResponse.text(); + setResult(prev => prev + ` ❌ Upload failed: ${errorText}\n`); + } + + } catch (error) { + setResult(prev => prev + `❌ Exception: ${error instanceof Error ? error.message : String(error)}\n`); + } finally { + setLoading(false); + } + }; + + const testAuth = async () => { + setLoading(true); + setResult('Testing JWT authentication...\n'); + + try { + // Step 1: Login to get JWT token + setResult(prev => prev + `\n1. Logging in to ${config.url}/api/login\n`); + + const loginResponse = await fetch(`${config.url}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: config.username, + password: config.password, + recaptcha: '', + }), + }); + + setResult(prev => prev + ` Status: ${loginResponse.status} ${loginResponse.statusText}\n`); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + setResult(prev => prev + ` ❌ Login failed: ${errorText}\n`); + return; + } + + const token = await loginResponse.text(); + setResult(prev => prev + ` ✅ Got JWT token: ${token.substring(0, 50)}...\n`); + + // Step 2: Test API with JWT token + setResult(prev => prev + `\n2. Testing GET ${config.url}/api/resources/\n`); + + const response = await fetch(`${config.url}/api/resources/`, { + method: 'GET', + headers: { + 'X-Auth': token, + }, + }); + + setResult(prev => prev + ` Status: ${response.status} ${response.statusText}\n`); + + if (response.ok) { + const data = await response.json(); + setResult(prev => prev + ` ✅ API call successful!\n${JSON.stringify(data, null, 2)}\n`); + } else { + const errorText = await response.text(); + setResult(prev => prev + ` ❌ API call failed: ${errorText}\n`); + } + + } catch (error) { + setResult(prev => prev + `❌ Exception: ${error instanceof Error ? error.message : String(error)}\n`); + } finally { + setLoading(false); + } + }; + + return ( +
+

FileBrowser Upload Test

+ + {/* Configuration */} +
+

Configuration

+ +
+ + setConfig(prev => ({ ...prev, url: e.target.value }))} + className="w-full px-3 py-2 border rounded" + placeholder="https://example.com" + /> +
+ +
+
+ + setConfig(prev => ({ ...prev, username: e.target.value }))} + className="w-full px-3 py-2 border rounded" + /> +
+ +
+ + setConfig(prev => ({ ...prev, password: e.target.value }))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ +
+ + setConfig(prev => ({ ...prev, path: e.target.value }))} + className="w-full px-3 py-2 border rounded" + placeholder="/" + /> +
+ +
+ + + +
+
+ + {/* Results */} +
+

Results

+
{result || 'No results yet...'}
+
+
+ ); +} diff --git a/components/terminal/hooks/use-file-drop.ts b/components/terminal/hooks/use-file-drop.ts new file mode 100644 index 0000000..6a58099 --- /dev/null +++ b/components/terminal/hooks/use-file-drop.ts @@ -0,0 +1,249 @@ +/** + * useFileDrop Hook + * + * Custom hook for handling file drag-and-drop and clipboard paste events. + * Monitors the entire window for file drops and pastes, making it work + * seamlessly even when xterm has focus. + * + * Features: + * - Global drag and drop support + * - Global paste event handling (works with xterm focus) + * - Folder and file extraction + * - Visual drag feedback state + * - Automatic event cleanup + * + * @example + * ```tsx + * const { isDragging } = useFileDrop({ + * enabled: true, + * onFilesDropped: (files) => console.log(files), + * onFilesPasted: (files) => console.log(files), + * }); + * ``` + */ + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface FileDropConfig { + /** Enable/disable file drop and paste handling */ + enabled?: boolean; + /** Callback when files are dropped */ + onFilesDropped?: (files: File[]) => void; + /** Callback when files are pasted */ + onFilesPasted?: (files: File[]) => void; + /** Container element to attach events to (defaults to window) */ + containerRef?: React.RefObject; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useFileDrop(config: FileDropConfig) { + const [isDragging, setIsDragging] = useState(false); + const dragCounterRef = useRef(0); + + /** + * Extract files from clipboard data + */ + const extractFilesFromClipboard = useCallback( + async (clipboardData: DataTransfer): Promise => { + const files: File[] = []; + const items = clipboardData.items; + + if (!items) return files; + + // Extract files from clipboard items + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + return files; + }, + [] + ); + + /** + * Extract files from DataTransfer (supports folders) + */ + const extractFilesFromDataTransfer = useCallback( + async (dataTransfer: DataTransfer): Promise => { + const { extractFilesFromDataTransfer: extractFiles } = await import( + '@/lib/utils/filebrowser' + ); + return extractFiles(dataTransfer); + }, + [] + ); + + /** + * Handle drag enter event + */ + const handleDragEnter = useCallback( + (e: DragEvent) => { + if (!config.enabled) return; + + e.preventDefault(); + e.stopPropagation(); + + dragCounterRef.current++; + + // Check if drag contains files + const hasFiles = Array.from(e.dataTransfer?.items || []).some( + (item) => item.kind === 'file' + ); + + if (hasFiles && dragCounterRef.current === 1) { + setIsDragging(true); + } + }, + [config.enabled] + ); + + /** + * Handle drag over event + */ + const handleDragOver = useCallback( + (e: DragEvent) => { + if (!config.enabled) return; + e.preventDefault(); + e.stopPropagation(); + }, + [config.enabled] + ); + + /** + * Handle drag leave event + */ + const handleDragLeave = useCallback( + (e: DragEvent) => { + if (!config.enabled) return; + + e.preventDefault(); + e.stopPropagation(); + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDragging(false); + } + }, + [config.enabled] + ); + + /** + * Handle drop event + */ + const handleDrop = useCallback( + async (e: DragEvent) => { + if (!config.enabled) return; + + e.preventDefault(); + e.stopPropagation(); + + // Reset drag state + dragCounterRef.current = 0; + setIsDragging(false); + + if (!e.dataTransfer) return; + + try { + const files = await extractFilesFromDataTransfer(e.dataTransfer); + + if (files.length > 0) { + config.onFilesDropped?.(files); + } + } catch (error) { + console.error('[useFileDrop] Failed to extract files from drop:', error); + } + }, + [config.enabled, config.onFilesDropped, extractFilesFromDataTransfer] + ); + + /** + * Handle paste event + */ + const handlePaste = useCallback( + async (e: ClipboardEvent) => { + if (!config.enabled) return; + + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + try { + const files = await extractFilesFromClipboard(clipboardData); + + if (files.length > 0) { + // Prevent default paste behavior when files are detected + e.preventDefault(); + e.stopPropagation(); + + config.onFilesPasted?.(files); + } + } catch (error) { + console.error('[useFileDrop] Failed to extract files from paste:', error); + } + }, + [config.enabled, config.onFilesPasted, extractFilesFromClipboard] + ); + + /** + * Setup event listeners + */ + useEffect(() => { + if (!config.enabled) return; + + // Use provided container or default to window + const target = config.containerRef?.current || window; + if (!target) return; + + // Add event listeners + // Note: drag events use bubble phase (default) + target.addEventListener('dragenter', handleDragEnter as any); + target.addEventListener('dragover', handleDragOver as any); + target.addEventListener('dragleave', handleDragLeave as any); + target.addEventListener('drop', handleDrop as any); + + // CRITICAL: Use capture phase for paste to intercept before xterm! + // xterm blocks paste event propagation, so we must listen in capture phase + target.addEventListener('paste', handlePaste as any, true); + + // Cleanup + return () => { + target.removeEventListener('dragenter', handleDragEnter as any); + target.removeEventListener('dragover', handleDragOver as any); + target.removeEventListener('dragleave', handleDragLeave as any); + target.removeEventListener('drop', handleDrop as any); + // Must match the addEventListener call (with capture=true) + target.removeEventListener('paste', handlePaste as any, true); + + // Reset state + dragCounterRef.current = 0; + setIsDragging(false); + }; + }, [ + config.enabled, + config.containerRef, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + handlePaste, + ]); + + return { + isDragging, + }; +} \ No newline at end of file diff --git a/components/terminal/hooks/use-file-upload.tsx b/components/terminal/hooks/use-file-upload.tsx new file mode 100644 index 0000000..a74aa0e --- /dev/null +++ b/components/terminal/hooks/use-file-upload.tsx @@ -0,0 +1,235 @@ +/** + * useFileUpload Hook + * + * Custom hook for handling file uploads to FileBrowser service. + * Provides seamless file upload functionality with progress tracking and toast notifications. + * + * Features: + * - Batch file upload support + * - Progress tracking with callbacks + * - Automatic toast notifications + * - Clipboard integration + * - Error handling + * + * @example + * ```tsx + * const { uploadFiles, isUploading } = useFileUpload({ + * fileBrowserUrl: 'https://...', + * fileBrowserUsername: 'admin', + * fileBrowserPassword: 'password', + * }); + * + * await uploadFiles([file1, file2]); + * ``` + */ + +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface FileUploadConfig { + fileBrowserUrl?: string; + fileBrowserUsername?: string; + fileBrowserPassword?: string; + enabled?: boolean; +} + +export interface UploadOptions { + /** Show toast notifications during upload */ + showToast?: boolean; + /** Copy path to clipboard after successful upload */ + copyToClipboard?: boolean; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useFileUpload(config: FileUploadConfig) { + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ + completed: number; + total: number; + currentFile: string; + } | null>(null); + + // Keep refs for stable callbacks + const uploadToastIdRef = useRef(null); + + // Check if upload is properly configured + const isConfigured = Boolean( + config.enabled !== false && + config.fileBrowserUrl && + config.fileBrowserUsername && + config.fileBrowserPassword + ); + + /** + * Upload multiple files to FileBrowser + */ + const uploadFiles = useCallback( + async ( + files: File[], + options: UploadOptions = { + showToast: true, + copyToClipboard: true, + } + ) => { + // Validation + if (!isConfigured) { + if (options.showToast) { + toast.error('File upload not configured', { + description: 'FileBrowser credentials are missing', + }); + } + throw new Error('File upload not configured'); + } + + if (files.length === 0) { + return; + } + + // Start upload + setIsUploading(true); + setUploadProgress({ completed: 0, total: files.length, currentFile: '' }); + + // Show initial toast + if (options.showToast) { + uploadToastIdRef.current = toast.loading( + `Uploading ${files.length} file(s)...`, + { + description: 'Please wait', + } + ); + } + + try { + // Dynamic import for tree-shaking + const { + uploadFilesToFileBrowser, + copyToClipboard, + formatFileSize, + } = await import('@/lib/utils/filebrowser'); + + // Upload with progress tracking + const result = await uploadFilesToFileBrowser( + config.fileBrowserUrl!, + config.fileBrowserUsername!, + config.fileBrowserPassword!, + files, + (completed: number, total: number, currentFile: string) => { + setUploadProgress({ completed, total, currentFile }); + + // Update loading toast + if (options.showToast && uploadToastIdRef.current) { + toast.loading(`Uploading ${completed}/${total} files...`, { + id: uploadToastIdRef.current, + description: currentFile ? `Current: ${currentFile}` : 'Processing...', + }); + } + } + ); + + // Dismiss loading toast + if (options.showToast && uploadToastIdRef.current) { + toast.dismiss(uploadToastIdRef.current); + uploadToastIdRef.current = null; + } + + // Handle results + if (result.succeeded.length > 0) { + const totalSize = result.succeeded.reduce((sum, r) => sum + r.size, 0); + const pathToCopy = + result.succeeded.length === 1 ? result.succeeded[0].path : result.rootPath; + + // Copy to clipboard + if (options.copyToClipboard) { + try { + await copyToClipboard(pathToCopy); + } catch (error) { + console.warn('[useFileUpload] Failed to copy to clipboard:', error); + } + } + + // Show success toast + if (options.showToast) { + if (result.failed.length === 0) { + // All succeeded + if (result.succeeded.length === 1) { + const file = result.succeeded[0]; + toast.success('File uploaded', { + description: `${file.filename} (${formatFileSize(file.size)}) • Path: ${pathToCopy}`, + duration: 5000, + }); + } else { + toast.success(`${result.succeeded.length} files uploaded`, { + description: `Total: ${formatFileSize(totalSize)} • Path: ${pathToCopy}`, + duration: 5000, + }); + } + } else { + // Partial success + const failedNames = result.failed.map((f) => f.filename).join(', '); + toast.warning( + `Uploaded ${result.succeeded.length} of ${result.total} files`, + { + description: `${formatFileSize(totalSize)} uploaded • Failed: ${failedNames}`, + duration: 6000, + } + ); + } + } + } else { + // All failed + if (options.showToast) { + toast.error('Upload failed', { + description: + result.failed.length > 0 + ? `Error: ${result.failed[0].error}` + : 'Unknown error occurred', + duration: 5000, + }); + } + } + + return result; + } catch (error) { + console.error('[useFileUpload] Upload error:', error); + + if (options.showToast) { + if (uploadToastIdRef.current) { + toast.dismiss(uploadToastIdRef.current); + uploadToastIdRef.current = null; + } + toast.error('Upload failed', { + description: error instanceof Error ? error.message : 'Unknown error', + duration: 5000, + }); + } + + throw error; + } finally { + setIsUploading(false); + setUploadProgress(null); + } + }, + [ + isConfigured, + config.fileBrowserUrl, + config.fileBrowserUsername, + config.fileBrowserPassword, + ] + ); + + return { + uploadFiles, + isUploading, + uploadProgress, + isConfigured, + }; +} \ No newline at end of file diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index fec47a5..a0a051f 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -27,6 +27,7 @@ type Project = Prisma.ProjectGetPayload<{ include: { sandboxes: true; databases: true; + environments: true; }; }>; @@ -49,6 +50,22 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) const [tabs, setTabs] = useState([{ id: '1', name: 'Terminal 1' }]); const [activeTabId, setActiveTabId] = useState('1'); + // ========================================================================= + // Extract FileBrowser Credentials + // ========================================================================= + + const fileBrowserCredentials = (() => { + const username = project.environments?.find((env) => env.key === 'FILE_BROWSER_USERNAME') + ?.value; + const password = project.environments?.find((env) => env.key === 'FILE_BROWSER_PASSWORD') + ?.value; + + if (username && password) { + return { username, password }; + } + return undefined; + })(); + // ========================================================================= // Tab Operations // ========================================================================= @@ -104,6 +121,7 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) onTabSelect={handleTabSelect} onTabClose={handleTabClose} onTabAdd={handleTabAdd} + fileBrowserCredentials={fileBrowserCredentials} /> {/* Terminal display area with tab switching */} @@ -122,6 +140,9 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) ttydUrl={sandbox?.ttydUrl} status={sandbox?.status ?? 'CREATING'} tabId={tab.id} + fileBrowserUrl={sandbox?.fileBrowserUrl} + fileBrowserUsername={fileBrowserCredentials?.username} + fileBrowserPassword={fileBrowserCredentials?.password} /> ))} diff --git a/components/terminal/terminal-display.tsx b/components/terminal/terminal-display.tsx index 4da0477..38a4ae5 100644 --- a/components/terminal/terminal-display.tsx +++ b/components/terminal/terminal-display.tsx @@ -35,6 +35,9 @@ export interface TerminalDisplayProps { ttydUrl?: string | null; status: string; tabId: string; + fileBrowserUrl?: string | null; + fileBrowserUsername?: string; + fileBrowserPassword?: string; } type ConnectionStatus = 'connecting' | 'connected' | 'error'; @@ -43,7 +46,14 @@ type ConnectionStatus = 'connecting' | 'connected' | 'error'; // Component // ============================================================================ -export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps) { +export function TerminalDisplay({ + ttydUrl, + status, + tabId, + fileBrowserUrl, + fileBrowserUsername, + fileBrowserPassword, +}: TerminalDisplayProps) { // ========================================================================= // State Management // ========================================================================= @@ -126,6 +136,10 @@ export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps onReady={handleReady} onConnected={handleConnected} onDisconnected={handleDisconnected} + fileBrowserUrl={fileBrowserUrl || undefined} + fileBrowserUsername={fileBrowserUsername} + fileBrowserPassword={fileBrowserPassword} + enableFileUpload={true} /> diff --git a/components/terminal/terminal-toolbar.tsx b/components/terminal/terminal-toolbar.tsx index 1604bba..f9a394c 100644 --- a/components/terminal/terminal-toolbar.tsx +++ b/components/terminal/terminal-toolbar.tsx @@ -8,7 +8,7 @@ import { useState } from 'react'; import type { Prisma } from '@prisma/client'; -import { Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; +import { Copy, Eye, EyeOff, Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; import { Dialog, @@ -49,6 +49,11 @@ export interface TerminalToolbarProps { onTabClose: (tabId: string) => void; /** Callback when new tab is added */ onTabAdd: () => void; + /** FileBrowser credentials (optional) */ + fileBrowserCredentials?: { + username: string; + password: string; + }; } /** @@ -62,13 +67,34 @@ export function TerminalToolbar({ onTabSelect, onTabClose, onTabAdd, + fileBrowserCredentials, }: TerminalToolbarProps) { const [showNetworkDialog, setShowNetworkDialog] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [copiedField, setCopiedField] = useState(null); + const networkEndpoints = [ { domain: sandbox?.publicUrl || '', port: 3000, protocol: 'HTTPS', label: 'Application' }, { domain: sandbox?.ttydUrl || '', port: 7681, protocol: 'HTTPS', label: 'Terminal' }, + { + domain: sandbox?.fileBrowserUrl || '', + port: 8080, + protocol: 'HTTPS', + label: 'File Browser', + hasCredentials: true, + }, ]; + const copyToClipboard = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + return ( <>
@@ -161,6 +187,68 @@ export function TerminalToolbar({ > {endpoint.domain} + + {/* Show credentials for File Browser */} + {endpoint.hasCredentials && fileBrowserCredentials && ( +
+
Login Credentials:
+ + {/* Username */} +
+
+
Username
+ + {fileBrowserCredentials.username} + +
+ +
+ + {/* Password */} +
+
+
Password
+ + {showPassword + ? fileBrowserCredentials.password + : '••••••••••••••••'} + +
+ + +
+
+ )}
))} diff --git a/components/terminal/xterm-terminal.tsx b/components/terminal/xterm-terminal.tsx index 2dd8d45..857cd06 100644 --- a/components/terminal/xterm-terminal.tsx +++ b/components/terminal/xterm-terminal.tsx @@ -10,12 +10,25 @@ * - Smart scroll behavior with indicator * - Multiple renderer support (WebGL, Canvas, DOM) * - Proper cleanup and memory management + * - Seamless file upload via drag & drop or paste (Ctrl+V) + * - Integration with FileBrowser + * + * File Upload UX: + * - Global paste support (works even when terminal has focus) + * - Background upload without blocking terminal interaction + * - Toast notifications for progress and status + * - No intrusive overlays */ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ITerminalOptions, Terminal as ITerminal } from '@xterm/xterm'; +import { Upload } from 'lucide-react'; +import { toast } from 'sonner'; + +import { useFileDrop } from './hooks/use-file-drop'; +import { useFileUpload } from './hooks/use-file-upload'; import '@xterm/xterm/css/xterm.css'; @@ -69,6 +82,11 @@ export interface XtermTerminalProps { onReady?: () => void; onConnected?: () => void; onDisconnected?: () => void; + // FileBrowser upload support + fileBrowserUrl?: string; + fileBrowserUsername?: string; + fileBrowserPassword?: string; + enableFileUpload?: boolean; } // ============================================================================ @@ -84,6 +102,10 @@ export function XtermTerminal({ onReady, onConnected, onDisconnected, + fileBrowserUrl, + fileBrowserUsername, + fileBrowserPassword, + enableFileUpload = true, }: XtermTerminalProps) { // ========================================================================= // State & Refs @@ -97,6 +119,49 @@ export function XtermTerminal({ const [hasNewContent, setHasNewContent] = useState(false); const [newLineCount, setNewLineCount] = useState(0); + // ========================================================================= + // File Upload Integration + // ========================================================================= + + // Setup file upload hook + const { uploadFiles, isUploading, isConfigured } = useFileUpload({ + fileBrowserUrl, + fileBrowserUsername, + fileBrowserPassword, + enabled: enableFileUpload, + }); + + // Handle file drop and paste events + const handleFilesReceived = useCallback( + async (files: File[], source: 'drop' | 'paste') => { + if (files.length === 0) return; + + // Show quick info toast + toast.info(`${source === 'paste' ? 'Pasted' : 'Dropped'} ${files.length} file(s)`, { + description: 'Starting upload...', + duration: 2000, + }); + + // Upload in background + try { + await uploadFiles(files, { + showToast: true, + copyToClipboard: true, + }); + } catch (error) { + console.error('[XtermTerminal] Upload failed:', error); + } + }, + [uploadFiles] + ); + + // Setup drag and drop / paste event handling + const { isDragging } = useFileDrop({ + enabled: isConfigured && !isUploading, + onFilesDropped: (files) => handleFilesReceived(files, 'drop'), + onFilesPasted: (files) => handleFilesReceived(files, 'paste'), + }); + // ========================================================================= // Memoized Configuration // ========================================================================= @@ -143,7 +208,7 @@ export function XtermTerminal({ const terminal = terminalRef.current; if (terminal) { terminal.scrollToBottom(); - console.log('[terminal] User scrolled to bottom'); + console.log('[XtermTerminal] User scrolled to bottom'); } setHasNewContent(false); setNewLineCount(0); @@ -206,7 +271,7 @@ export function XtermTerminal({ const token = url.searchParams.get('arg') || ''; if (!token) { - console.error('[terminal] No authentication token found in URL'); + console.error('[XtermTerminal] No authentication token found in URL'); return null; } @@ -214,10 +279,10 @@ export function XtermTerminal({ const wsPath = url.pathname.replace(/\/$/, '') + '/ws'; const wsFullUrl = `${wsProtocol}//${url.host}${wsPath}${url.search}`; - console.log('[terminal] Connecting to:', wsFullUrl.replace(token, '***')); + console.log('[XtermTerminal] Connecting to:', wsFullUrl.replace(token, '***')); return { wsFullUrl, token }; } catch (error) { - console.error('[terminal] Failed to parse URL:', error); + console.error('[XtermTerminal] Failed to parse URL:', error); return null; } }; @@ -268,9 +333,9 @@ export function XtermTerminal({ const { WebglAddon: WebglAddonClass } = await import('@xterm/addon-webgl'); webglAddon = new WebglAddonClass(); terminal.loadAddon(webglAddon); - console.log('[terminal] WebGL renderer loaded'); + console.log('[XtermTerminal] WebGL renderer loaded'); } catch (e) { - console.log('[terminal] WebGL failed, falling back to canvas', e); + console.log('[XtermTerminal] WebGL failed, falling back to canvas', e); await applyRenderer('canvas'); } break; @@ -279,13 +344,13 @@ export function XtermTerminal({ const { CanvasAddon: CanvasAddonClass } = await import('@xterm/addon-canvas'); canvasAddon = new CanvasAddonClass(); terminal.loadAddon(canvasAddon); - console.log('[terminal] Canvas renderer loaded'); + console.log('[XtermTerminal] Canvas renderer loaded'); } catch (e) { - console.log('[terminal] Canvas failed, using DOM', e); + console.log('[XtermTerminal] Canvas failed, using DOM', e); } break; case 'dom': - console.log('[terminal] DOM renderer loaded'); + console.log('[XtermTerminal] DOM renderer loaded'); break; } }; @@ -305,13 +370,13 @@ export function XtermTerminal({ const { wsFullUrl, token } = urlInfo; - console.log('[terminal] Creating WebSocket connection...'); + console.log('[XtermTerminal] Creating WebSocket connection...'); socket = new WebSocket(wsFullUrl, ['tty']); socket.binaryType = 'arraybuffer'; socket.onopen = () => { if (!isMounted) return; - console.log('[terminal] WebSocket connected'); + console.log('[XtermTerminal] WebSocket connected'); stableOnConnected(); const authMsg = JSON.stringify({ @@ -350,17 +415,17 @@ export function XtermTerminal({ document.title = textDecoder.decode(data); break; case Command.SET_PREFERENCES: - console.log('[terminal] Preferences:', textDecoder.decode(data)); + console.log('[XtermTerminal] Preferences:', textDecoder.decode(data)); break; default: - console.warn('[terminal] Unknown command:', cmd); + console.warn('[XtermTerminal] Unknown command:', cmd); } }; socket.onclose = (event: CloseEvent) => { if (!isMounted) return; - console.log('[terminal] WebSocket closed:', event.code, event.reason); + console.log('[XtermTerminal] WebSocket closed:', event.code, event.reason); socket = null; stableOnDisconnected(); @@ -375,7 +440,7 @@ export function XtermTerminal({ }; socket.onerror = (error) => { - console.error('[terminal] WebSocket error:', error); + console.error('[XtermTerminal] WebSocket error:', error); }; }; @@ -484,9 +549,9 @@ export function XtermTerminal({ stableOnReady(); connectWebSocket(); - console.log('[terminal] Initialization complete'); + console.log('[XtermTerminal] Initialization complete'); } catch (error) { - console.error('[terminal] Initialization failed:', error); + console.error('[XtermTerminal] Initialization failed:', error); } }; @@ -497,7 +562,7 @@ export function XtermTerminal({ // ----------------------------------------------------------------------- return () => { - console.log('[terminal] Cleaning up'); + console.log('[XtermTerminal] Cleaning up'); isMounted = false; if (reconnectTimeout) clearTimeout(reconnectTimeout); @@ -533,6 +598,7 @@ export function XtermTerminal({
+ {/* Scroll to bottom button */} {hasNewContent && ( )} + + {/* Subtle drag indicator - only shown when dragging files */} + {isConfigured && isDragging && ( +
+ + Drop files to upload +
+ )} + + {/* Upload progress indicator - shown as floating badge */} + {isUploading && ( +
+
+ + + + +
+ Uploading... +
+ )}
); -} \ No newline at end of file +} diff --git a/docs/fixes/paste-event-capture-fix.md b/docs/fixes/paste-event-capture-fix.md new file mode 100644 index 0000000..b07bfd2 --- /dev/null +++ b/docs/fixes/paste-event-capture-fix.md @@ -0,0 +1,331 @@ +# Fix: Terminal Paste Event Not Working for Images + +**Date**: 2025-11-18 +**Version**: v0.4.x +**Status**: ✅ Fixed +**Severity**: High +**Component**: Terminal File Upload + +--- + +## Problem Description + +### Symptoms + +- ✅ **Text paste (Ctrl+V)** worked correctly in terminal - text was displayed +- ❌ **Image paste (Ctrl+V)** did NOT trigger file upload - no logs, no upload, no response +- ❌ Clipboard images were completely ignored when pasting into the terminal + +### User Impact + +Users could not paste screenshots or images directly into the terminal for upload, forcing them to use drag-and-drop as the only option. This significantly reduced usability for workflows involving image sharing. + +### Root Cause + +The issue was caused by **xterm.js blocking paste event propagation** in the bubble phase. + +#### Event Flow Analysis + +``` +User presses Ctrl+V + ↓ +Browser triggers paste event on xterm's hidden textarea (target element) + ↓ +xterm.js internal handler processes the event + ↓ +xterm calls e.preventDefault() or e.stopPropagation() + ↓ +Event propagation stops ❌ + ↓ +Our window.addEventListener('paste', handler) never receives the event + (listening in bubble phase) +``` + +#### Why Text Paste Worked But Image Paste Didn't + +- **Text paste**: xterm successfully extracts text from `clipboardData` and displays it in the terminal +- **Image paste**: xterm doesn't support images, ignores the clipboardData, but **still blocks event propagation** +- Our listener in bubble phase never gets a chance to handle the image data + +--- + +## Technical Analysis + +### JavaScript Event Propagation Phases + +JavaScript events propagate through three phases: + +``` +1. CAPTURE PHASE (top → down) + window → document → body → ... → target + +2. TARGET PHASE + Event fires on the target element + +3. BUBBLE PHASE (bottom → up) + target → ... → body → document → window +``` + +### The Bug + +```typescript +// Old code - listening in BUBBLE phase +window.addEventListener('paste', handlePaste); +// Equivalent to: +window.addEventListener('paste', handlePaste, false); +``` + +**Problem**: xterm blocks the event before it reaches the bubble phase. + +### Why Rich Text Editors Don't Have This Problem + +Rich text editors (`
`) don't block paste event propagation: +- They process the paste event but allow it to bubble +- Our window listener receives the event normally + +xterm.js uses a **hidden textarea** with special event handling: +- Captures paste events exclusively +- Prevents event bubbling for internal processing +- This is intentional xterm behavior, not a bug + +--- + +## Solution + +### The Fix + +Use **event capture phase** instead of bubble phase: + +```typescript +// Fixed code - listening in CAPTURE phase +window.addEventListener('paste', handlePaste, true); +// ^^^^ +// Third parameter = true +``` + +### Why This Works + +``` +User presses Ctrl+V + ↓ +Browser creates ClipboardEvent + ↓ +CAPTURE PHASE starts from window ← We intercept here! ✅ + ↓ +Our listener on window (capture) fires FIRST + ↓ +We extract file from clipboardData.items + ↓ +If file detected: e.preventDefault() → Block xterm from processing + ↓ +Upload file to FileBrowser ✅ + ↓ +If no file detected: Let event continue to xterm (text paste still works) +``` + +### Key Insight + +By using capture phase, we intercept the paste event **before xterm sees it**, giving us first chance to check for files. If we find files, we handle them and stop propagation. If it's just text, we let it pass through to xterm normally. + +--- + +## Implementation + +### Modified Files + +1. **`components/terminal/hooks/use-file-drop.ts`** + +```typescript +// Before (didn't work) +target.addEventListener('paste', handlePaste); + +// After (works!) +target.addEventListener('paste', handlePaste, true); +// ^^^^ +// capture phase +``` + +**CRITICAL**: Cleanup must also use capture phase: + +```typescript +// Must match addEventListener parameters +target.removeEventListener('paste', handlePaste, true); +// ^^^^ +``` + +### Code Changes + +**Line 221** in `use-file-drop.ts`: +```typescript +// CRITICAL: Use capture phase for paste to intercept before xterm! +// xterm blocks paste event propagation, so we must listen in capture phase +target.addEventListener('paste', handlePaste as any, true); +``` + +**Line 230** in `use-file-drop.ts`: +```typescript +// Must match the addEventListener call (with capture=true) +target.removeEventListener('paste', handlePaste as any, true); +``` + +### No Other Changes Needed + +- ✅ Drag-and-drop events still use bubble phase (default) - they work fine +- ✅ No changes to UI components +- ✅ No changes to upload logic +- ✅ No changes to FileBrowser integration + +--- + +## Testing + +### Test Cases + +#### ✅ Test 1: Image Paste (Primary Fix) + +1. Copy an image to clipboard (screenshot or copy from web) +2. Focus on terminal +3. Press `Ctrl+V` (or `Cmd+V` on Mac) + +**Expected**: +- Toast notification: "Pasted 1 file(s)" +- File uploads to FileBrowser +- Success toast with file path +- Path copied to clipboard + +**Result**: ✅ PASSED + +#### ✅ Test 2: Text Paste (Regression Test) + +1. Copy some text +2. Focus on terminal +3. Press `Ctrl+V` + +**Expected**: +- Text appears in terminal normally +- No upload triggered + +**Result**: ✅ PASSED (text paste still works) + +#### ✅ Test 3: Drag and Drop (Regression Test) + +1. Drag file to terminal +2. Drop file + +**Expected**: +- File uploads normally + +**Result**: ✅ PASSED + +### Browser Compatibility + +Tested and confirmed working on: +- ✅ Chrome 120+ (Linux, macOS, Windows) +- ✅ Firefox 121+ (Linux, macOS, Windows) +- ✅ Safari 17+ (macOS) +- ✅ Edge 120+ (Windows) + +**Note**: Some browsers require HTTPS for clipboard API access (security requirement). + +--- + +## Related Issues + +### Why addEventListener Third Parameter Matters + +The third parameter of `addEventListener` controls event phase: + +```typescript +target.addEventListener(type, listener, options); +``` + +**Options** can be: +- `boolean`: + - `true` = capture phase (top-down) + - `false` = bubble phase (bottom-up) **[default]** +- `object`: + ```typescript + { + capture: boolean, + once: boolean, + passive: boolean, + } + ``` + +### Why We Don't Use Capture for Drag Events + +Drag events (`dragenter`, `dragover`, `dragleave`, `drop`) work fine with bubble phase because: +- xterm.js doesn't intercept drag events +- No special handling needed +- Standard event propagation works + +--- + +## Prevention + +### Best Practices Learned + +1. **Test with components that manipulate DOM events** + - Libraries like xterm, monaco-editor, etc. often intercept events + - Test both capture and bubble phases when integrating + +2. **Use capture phase for critical events** + - When you need guaranteed access to an event before other handlers + - Especially useful with third-party components + +3. **Document event handling strategies** + - Future developers should know why capture phase is used + - Add comments explaining non-standard event handling + +### Code Review Checklist + +When adding event listeners to components wrapping third-party libraries: + +- [ ] Does the library intercept or block events? +- [ ] Should we use capture phase instead of bubble? +- [ ] Are both addEventListener and removeEventListener consistent? +- [ ] Is there a comment explaining non-standard event handling? + +--- + +## References + +- **xterm.js**: https://github.com/xtermjs/xterm.js +- **MDN - Event Capture**: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_capture +- **MDN - addEventListener**: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener +- **Clipboard API**: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API + +--- + +## Changelog + +### v0.4.x - 2025-11-18 + +**Fixed**: +- Terminal paste event not triggering for images due to xterm event blocking +- Added event capture phase handling for paste events + +**Changed**: +- `use-file-drop.ts`: Updated paste event listener to use capture phase + +**Impact**: +- Users can now paste images directly into terminal with Ctrl+V +- No breaking changes - text paste still works normally +- Drag-and-drop functionality unchanged + +--- + +## Summary + +**One-line fix with major impact**: + +```diff +- target.addEventListener('paste', handlePaste as any); ++ target.addEventListener('paste', handlePaste as any, true); +``` + +This single-character change (`true`) enables Ctrl+V image paste by intercepting the event before xterm.js can block it. The fix is elegant, minimal, and fully backward-compatible. + +**Status**: ✅ Fixed and tested +**Regression risk**: None - text paste verified working +**User impact**: High positive - major UX improvement \ No newline at end of file diff --git a/lib/k8s/sandbox-manager.ts b/lib/k8s/sandbox-manager.ts index ef31e30..61288fe 100644 --- a/lib/k8s/sandbox-manager.ts +++ b/lib/k8s/sandbox-manager.ts @@ -1245,6 +1245,17 @@ echo "=== Init Container: Completed successfully ===" 'nginx.ingress.kubernetes.io/proxy-buffer-size': '64k', 'nginx.ingress.kubernetes.io/proxy-send-timeout': '300', 'nginx.ingress.kubernetes.io/proxy-read-timeout': '300', + // CORS configuration for TUS resumable file uploads from browser + 'nginx.ingress.kubernetes.io/enable-cors': 'true', + 'nginx.ingress.kubernetes.io/cors-allow-origin': '*', + 'nginx.ingress.kubernetes.io/cors-allow-methods': + 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS', + 'nginx.ingress.kubernetes.io/cors-allow-headers': + 'DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Auth,Upload-Length,Upload-Offset,Tus-Resumable,Upload-Metadata,Upload-Defer-Length,Upload-Concat', + 'nginx.ingress.kubernetes.io/cors-expose-headers': + 'Upload-Offset,Location,Upload-Length,Tus-Version,Tus-Resumable,Tus-Max-Size,Tus-Extension,Upload-Metadata', + 'nginx.ingress.kubernetes.io/cors-allow-credentials': 'true', + 'nginx.ingress.kubernetes.io/cors-max-age': '1728000', 'nginx.ingress.kubernetes.io/server-snippet': 'client_header_buffer_size 64k;\nlarge_client_header_buffers 4 128k;', }, diff --git a/lib/utils/filebrowser.ts b/lib/utils/filebrowser.ts new file mode 100644 index 0000000..5b27f2c --- /dev/null +++ b/lib/utils/filebrowser.ts @@ -0,0 +1,333 @@ +/** + * FileBrowser API Utilities + * + * FileBrowser integration for file uploads via TUS protocol (Tus Resumable Upload). + * Authentication uses JWT tokens with X-Auth header (not Basic Auth). + * + * Flow: + * 1. Login: POST /api/login → Get JWT token + * 2. Upload: TUS protocol via /api/tus/{filename}?override=false + * 3. Files are uploaded to FileBrowser root directory (/) + */ + +// ============================================================================ +// Types +// ============================================================================ + +export interface UploadResult { + /** Full path of uploaded file (e.g., /image.png) */ + path: string + /** Original filename */ + filename: string + /** File size in bytes */ + size: number + /** Whether file is an image */ + isImage: boolean +} + +export interface UploadBatchResult { + /** Successfully uploaded files */ + succeeded: UploadResult[] + /** Failed uploads with error messages */ + failed: Array<{ filename: string; error: string }> + /** Total number of files attempted */ + total: number + /** Whether all successful uploads are images */ + allImages: boolean + /** Root path containing all uploaded files (always "/" for FileBrowser) */ + rootPath: string +} + +// ============================================================================ +// Authentication +// ============================================================================ + +/** + * Login to FileBrowser and get JWT token + * + * @param fileBrowserUrl - Base URL of FileBrowser (e.g., https://example.com) + * @param username - FileBrowser username + * @param password - FileBrowser password + * @returns JWT token for subsequent API calls + * @throws Error if login fails + */ +export async function loginToFileBrowser( + fileBrowserUrl: string, + username: string, + password: string +): Promise { + const response = await fetch(`${fileBrowserUrl}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + recaptcha: '', + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Login failed (${response.status}): ${errorText}`) + } + + // FileBrowser returns JWT token as plain text + const token = await response.text() + return token +} + +// ============================================================================ +// File Upload (TUS Protocol) +// ============================================================================ + +/** + * Upload a single file to FileBrowser using TUS protocol + * + * TUS (Tus Resumable Upload) protocol: + * - POST /api/tus/{filename}?override=false - Create upload session + * - PATCH /api/tus/{filename} - Upload file content in chunks + * + * @param fileBrowserUrl - Base URL of FileBrowser + * @param token - JWT token from loginToFileBrowser() + * @param file - File to upload + * @returns Upload result with file path and metadata + * @throws Error if upload fails + */ +export async function uploadFileToFileBrowser( + fileBrowserUrl: string, + token: string, + file: File +): Promise { + // Dynamic import for client-side only (tus-js-client uses browser APIs) + const tus = await import('tus-js-client') + + // FileBrowser TUS endpoint format: /api/tus/{filename}?override=false + // Filename must be in URL, not in metadata + const encodedFilename = encodeURIComponent(file.name) + const tusEndpoint = `${fileBrowserUrl}/api/tus/${encodedFilename}?override=false` + + return new Promise((resolve, reject) => { + const upload = new tus.Upload(file, { + endpoint: tusEndpoint, + headers: { + 'X-Auth': token, + }, + chunkSize: 5 * 1024 * 1024, // 5MB chunks for large files + retryDelays: [0, 1000, 3000, 5000], // Retry on failure + onError: (error) => { + reject(new Error(`Upload failed: ${error.message}`)) + }, + onSuccess: () => { + // FileBrowser uploads to root directory + const uploadedPath = `/${file.name}` + resolve({ + path: uploadedPath, + filename: file.name, + size: file.size, + isImage: isImageFile(file), + }) + }, + }) + + upload.start() + }) +} + +/** + * Upload multiple files with automatic login and progress tracking + * + * @param fileBrowserUrl - Base URL of FileBrowser + * @param username - FileBrowser username + * @param password - FileBrowser password + * @param files - Array of files to upload + * @param onProgress - Optional callback for upload progress + * @returns Batch upload result with succeeded/failed files + */ +export async function uploadFilesToFileBrowser( + fileBrowserUrl: string, + username: string, + password: string, + files: File[], + onProgress?: (completed: number, total: number, currentFile: string) => void +): Promise { + // Login once and reuse token for all uploads + const token = await loginToFileBrowser(fileBrowserUrl, username, password) + + const succeeded: UploadResult[] = [] + const failed: Array<{ filename: string; error: string }> = [] + const total = files.length + + // Upload files sequentially to avoid overwhelming the server + for (let i = 0; i < files.length; i++) { + const file = files[i] + onProgress?.(i, total, file.name) + + try { + const result = await uploadFileToFileBrowser(fileBrowserUrl, token, file) + succeeded.push(result) + } catch (error) { + failed.push({ + filename: file.name, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + // Final progress update + onProgress?.(total, total, '') + + const allImages = succeeded.length > 0 && succeeded.every((r) => r.isImage) + + return { + succeeded, + failed, + total, + allImages, + rootPath: '/', // FileBrowser uploads to root directory + } +} + +// ============================================================================ +// File Type Detection +// ============================================================================ + +/** + * Check if file is an image based on MIME type + */ +function isImageFile(file: File): boolean { + return file.type.startsWith('image/') +} + +// ============================================================================ +// File Size Formatting +// ============================================================================ + +/** + * Format file size in human-readable format (Bytes, KB, MB, GB) + * + * @example + * formatFileSize(1024) // "1 KB" + * formatFileSize(1536) // "1.5 KB" + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} + +// ============================================================================ +// Drag & Drop / Clipboard Support +// ============================================================================ + +/** + * Extract files from DataTransfer (drag & drop or paste events) + * + * Handles: + * - Single files + * - Multiple files + * - Directories (recursively) + * + * @param dataTransfer - DataTransfer from drag/drop or paste event + * @returns Array of File objects + */ +export async function extractFilesFromDataTransfer(dataTransfer: DataTransfer): Promise { + const files: File[] = [] + + // Process file system entries (supports directories) + async function processEntry(entry: FileSystemEntry, basePath: string = ''): Promise { + if (entry.isFile) { + // Extract file from FileSystemFileEntry + const fileEntry = entry as FileSystemFileEntry + const file = await new Promise((resolve, reject) => { + fileEntry.file(resolve, reject) + }) + + // Preserve directory structure in filename if in subdirectory + if (basePath) { + const path = `${basePath}/${file.name}` + const newFile = new File([file], path, { type: file.type }) + files.push(newFile) + } else { + files.push(file) + } + } else if (entry.isDirectory) { + // Recursively process directory entries + const dirEntry = entry as FileSystemDirectoryEntry + const reader = dirEntry.createReader() + + let allEntries: FileSystemEntry[] = [] + let entries: FileSystemEntry[] + + // Read all entries (may require multiple calls) + do { + entries = await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) + allEntries = allEntries.concat(entries) + } while (entries.length > 0) + + // Process subdirectories recursively + const newBasePath = basePath ? `${basePath}/${entry.name}` : entry.name + for (const childEntry of allEntries) { + await processEntry(childEntry, newBasePath) + } + } + } + + // Try to use FileSystem API (supports directories) + if (dataTransfer.items) { + const items = Array.from(dataTransfer.items) + for (const item of items) { + const entry = item.webkitGetAsEntry?.() + if (entry) { + await processEntry(entry) + } + } + } + + // Fallback to simple file list (no directory support) + if (files.length === 0 && dataTransfer.files) { + files.push(...Array.from(dataTransfer.files)) + } + + return files +} + +// ============================================================================ +// Clipboard Utilities +// ============================================================================ + +/** + * Copy text to clipboard with fallback for older browsers + * + * @param text - Text to copy + * @throws Error if copy fails + */ +export async function copyToClipboard(text: string): Promise { + // Try modern Clipboard API first + try { + await navigator.clipboard.writeText(text) + return + } catch { + // Fallback for older browsers or security restrictions + } + + // Fallback: Create temporary textarea + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + document.body.appendChild(textArea) + textArea.select() + + try { + // Note: document.execCommand is deprecated but needed for fallback + document.execCommand('copy') + } finally { + document.body.removeChild(textArea) + } +} diff --git a/package.json b/package.json index 1de7cc2..356ff5b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-dom": "19.1.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "tus-js-client": "^4.3.1", "zod": "^4.1.12" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d63d466..6b4efff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + tus-js-client: + specifier: ^4.3.1 + version: 4.3.1 zod: specifier: ^4.1.12 version: 4.1.12 @@ -1582,6 +1585,9 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -1637,6 +1643,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combine-errors@3.0.3: + resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1662,6 +1671,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + custom-error-instance@2.1.1: + resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2202,6 +2214,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -2248,6 +2264,9 @@ packages: jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -2383,6 +2402,24 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash._baseiteratee@4.7.0: + resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} + + lodash._basetostring@4.12.0: + resolution: {integrity: sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==} + + lodash._baseuniq@4.6.0: + resolution: {integrity: sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==} + + lodash._createset@4.0.3: + resolution: {integrity: sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==} + + lodash._root@3.0.1: + resolution: {integrity: sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==} + + lodash._stringtopath@4.8.0: + resolution: {integrity: sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2407,6 +2444,12 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniqby@4.5.0: + resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2684,6 +2727,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2697,6 +2743,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2764,6 +2813,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2780,6 +2832,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2861,6 +2917,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -3005,6 +3064,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tus-js-client@4.3.1: + resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==} + engines: {node: '>=18'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -3052,6 +3115,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4486,6 +4552,8 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -4549,6 +4617,11 @@ snapshots: color-name@1.1.4: {} + combine-errors@3.0.3: + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4569,6 +4642,8 @@ snapshots: csstype@3.1.3: {} + custom-error-instance@2.1.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -5250,6 +5325,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -5297,6 +5374,8 @@ snapshots: jose@6.1.0: {} + js-base64@3.7.8: {} + js-cookie@3.0.5: {} js-tokens@4.0.0: {} @@ -5422,6 +5501,25 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash._baseiteratee@4.7.0: + dependencies: + lodash._stringtopath: 4.8.0 + + lodash._basetostring@4.12.0: {} + + lodash._baseuniq@4.6.0: + dependencies: + lodash._createset: 4.0.3 + lodash._root: 3.0.1 + + lodash._createset@4.0.3: {} + + lodash._root@3.0.1: {} + + lodash._stringtopath@4.8.0: + dependencies: + lodash._basetostring: 4.12.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -5438,6 +5536,13 @@ snapshots: lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} + + lodash.uniqby@4.5.0: + dependencies: + lodash._baseiteratee: 4.7.0 + lodash._baseuniq: 4.6.0 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5700,6 +5805,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + proxy-from-env@1.1.0: {} pump@3.0.3: @@ -5711,6 +5822,8 @@ snapshots: pure-rand@6.1.0: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -5780,6 +5893,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5796,6 +5911,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} + reusify@1.1.0: {} rfc4648@1.5.4: {} @@ -5919,6 +6036,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -6095,6 +6214,16 @@ snapshots: tslib@2.8.1: {} + tus-js-client@4.3.1: + dependencies: + buffer-from: 1.1.2 + combine-errors: 3.0.3 + is-stream: 2.0.1 + js-base64: 3.7.8 + lodash.throttle: 4.1.1 + proper-lockfile: 4.1.2 + url-parse: 1.5.10 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -6177,6 +6306,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.1.0): dependencies: react: 19.1.0 diff --git a/public/icon.svg b/public/favicon.svg similarity index 100% rename from public/icon.svg rename to public/favicon.svg From ae9fefe049df45da3b91eee21d0400e3c583e6db Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 19 Nov 2025 09:18:02 +0000 Subject: [PATCH 3/5] chore --- components/terminal/hooks/use-file-drop.ts | 172 +++++++++--------- components/terminal/hooks/use-file-upload.tsx | 48 ++--- components/terminal/terminal-toolbar.tsx | 14 +- components/terminal/xterm-terminal.tsx | 74 +------- lib/util/common-web.ts | 58 ++++++ lib/{utils => util}/filebrowser.ts | 37 +--- 6 files changed, 181 insertions(+), 222 deletions(-) create mode 100644 lib/util/common-web.ts rename lib/{utils => util}/filebrowser.ts (90%) diff --git a/components/terminal/hooks/use-file-drop.ts b/components/terminal/hooks/use-file-drop.ts index 6a58099..846f8fd 100644 --- a/components/terminal/hooks/use-file-drop.ts +++ b/components/terminal/hooks/use-file-drop.ts @@ -22,9 +22,9 @@ * ``` */ -'use client'; +'use client' -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react' // ============================================================================ // Types @@ -32,13 +32,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; export interface FileDropConfig { /** Enable/disable file drop and paste handling */ - enabled?: boolean; + enabled?: boolean /** Callback when files are dropped */ - onFilesDropped?: (files: File[]) => void; + onFilesDropped?: (files: File[]) => void /** Callback when files are pasted */ - onFilesPasted?: (files: File[]) => void; + onFilesPasted?: (files: File[]) => void /** Container element to attach events to (defaults to window) */ - containerRef?: React.RefObject; + containerRef?: React.RefObject } // ============================================================================ @@ -46,193 +46,195 @@ export interface FileDropConfig { // ============================================================================ export function useFileDrop(config: FileDropConfig) { - const [isDragging, setIsDragging] = useState(false); - const dragCounterRef = useRef(0); + const [isDragging, setIsDragging] = useState(false) + const dragCounterRef = useRef(0) /** * Extract files from clipboard data */ const extractFilesFromClipboard = useCallback( async (clipboardData: DataTransfer): Promise => { - const files: File[] = []; - const items = clipboardData.items; + const files: File[] = [] + const items = clipboardData.items - if (!items) return files; + if (!items) return files // Extract files from clipboard items for (let i = 0; i < items.length; i++) { - const item = items[i]; + const item = items[i] if (item.kind === 'file') { - const file = item.getAsFile(); + const file = item.getAsFile() if (file) { - files.push(file); + files.push(file) } } } - return files; + return files }, [] - ); + ) /** * Extract files from DataTransfer (supports folders) */ const extractFilesFromDataTransfer = useCallback( async (dataTransfer: DataTransfer): Promise => { - const { extractFilesFromDataTransfer: extractFiles } = await import( - '@/lib/utils/filebrowser' - ); - return extractFiles(dataTransfer); + const { extractFilesFromDataTransfer: extractFiles } = await import('@/lib/util/filebrowser') + return extractFiles(dataTransfer) }, [] - ); + ) /** * Handle drag enter event */ const handleDragEnter = useCallback( - (e: DragEvent) => { - if (!config.enabled) return; + (evt: Event) => { + if (!config.enabled) return - e.preventDefault(); - e.stopPropagation(); + const e = evt as DragEvent + e.preventDefault() + e.stopPropagation() - dragCounterRef.current++; + dragCounterRef.current++ // Check if drag contains files - const hasFiles = Array.from(e.dataTransfer?.items || []).some( - (item) => item.kind === 'file' - ); + const hasFiles = Array.from(e.dataTransfer?.items || []).some((item) => item.kind === 'file') if (hasFiles && dragCounterRef.current === 1) { - setIsDragging(true); + setIsDragging(true) } }, [config.enabled] - ); + ) /** * Handle drag over event */ const handleDragOver = useCallback( - (e: DragEvent) => { - if (!config.enabled) return; - e.preventDefault(); - e.stopPropagation(); + (evt: Event) => { + if (!config.enabled) return + + const e = evt as DragEvent + e.preventDefault() + e.stopPropagation() }, [config.enabled] - ); + ) /** * Handle drag leave event */ const handleDragLeave = useCallback( - (e: DragEvent) => { - if (!config.enabled) return; + (evt: Event) => { + if (!config.enabled) return - e.preventDefault(); - e.stopPropagation(); + const e = evt as DragEvent + e.preventDefault() + e.stopPropagation() - dragCounterRef.current--; + dragCounterRef.current-- if (dragCounterRef.current === 0) { - setIsDragging(false); + setIsDragging(false) } }, [config.enabled] - ); + ) /** * Handle drop event */ const handleDrop = useCallback( - async (e: DragEvent) => { - if (!config.enabled) return; + async (evt: Event) => { + if (!config.enabled) return - e.preventDefault(); - e.stopPropagation(); + const e = evt as DragEvent + e.preventDefault() + e.stopPropagation() // Reset drag state - dragCounterRef.current = 0; - setIsDragging(false); + dragCounterRef.current = 0 + setIsDragging(false) - if (!e.dataTransfer) return; + if (!e.dataTransfer) return try { - const files = await extractFilesFromDataTransfer(e.dataTransfer); + const files = await extractFilesFromDataTransfer(e.dataTransfer) if (files.length > 0) { - config.onFilesDropped?.(files); + config.onFilesDropped?.(files) } } catch (error) { - console.error('[useFileDrop] Failed to extract files from drop:', error); + console.error('[useFileDrop] Failed to extract files from drop:', error) } }, - [config.enabled, config.onFilesDropped, extractFilesFromDataTransfer] - ); + [config, extractFilesFromDataTransfer] + ) /** * Handle paste event */ const handlePaste = useCallback( - async (e: ClipboardEvent) => { - if (!config.enabled) return; + async (evt: Event) => { + if (!config.enabled) return - const clipboardData = e.clipboardData; - if (!clipboardData) return; + const e = evt as ClipboardEvent + const clipboardData = e.clipboardData + if (!clipboardData) return try { - const files = await extractFilesFromClipboard(clipboardData); + const files = await extractFilesFromClipboard(clipboardData) if (files.length > 0) { // Prevent default paste behavior when files are detected - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() - config.onFilesPasted?.(files); + config.onFilesPasted?.(files) } } catch (error) { - console.error('[useFileDrop] Failed to extract files from paste:', error); + console.error('[useFileDrop] Failed to extract files from paste:', error) } }, - [config.enabled, config.onFilesPasted, extractFilesFromClipboard] - ); + [config, extractFilesFromClipboard] + ) /** * Setup event listeners */ useEffect(() => { - if (!config.enabled) return; + if (!config.enabled) return // Use provided container or default to window - const target = config.containerRef?.current || window; - if (!target) return; + const target = config.containerRef?.current || window + if (!target) return // Add event listeners // Note: drag events use bubble phase (default) - target.addEventListener('dragenter', handleDragEnter as any); - target.addEventListener('dragover', handleDragOver as any); - target.addEventListener('dragleave', handleDragLeave as any); - target.addEventListener('drop', handleDrop as any); + target.addEventListener('dragenter', handleDragEnter) + target.addEventListener('dragover', handleDragOver) + target.addEventListener('dragleave', handleDragLeave) + target.addEventListener('drop', handleDrop) // CRITICAL: Use capture phase for paste to intercept before xterm! // xterm blocks paste event propagation, so we must listen in capture phase - target.addEventListener('paste', handlePaste as any, true); + target.addEventListener('paste', handlePaste, true) // Cleanup return () => { - target.removeEventListener('dragenter', handleDragEnter as any); - target.removeEventListener('dragover', handleDragOver as any); - target.removeEventListener('dragleave', handleDragLeave as any); - target.removeEventListener('drop', handleDrop as any); + target.removeEventListener('dragenter', handleDragEnter) + target.removeEventListener('dragover', handleDragOver) + target.removeEventListener('dragleave', handleDragLeave) + target.removeEventListener('drop', handleDrop) // Must match the addEventListener call (with capture=true) - target.removeEventListener('paste', handlePaste as any, true); + target.removeEventListener('paste', handlePaste, true) // Reset state - dragCounterRef.current = 0; - setIsDragging(false); - }; + dragCounterRef.current = 0 + setIsDragging(false) + } }, [ config.enabled, config.containerRef, @@ -241,9 +243,9 @@ export function useFileDrop(config: FileDropConfig) { handleDragLeave, handleDrop, handlePaste, - ]); + ]) return { isDragging, - }; -} \ No newline at end of file + } +} diff --git a/components/terminal/hooks/use-file-upload.tsx b/components/terminal/hooks/use-file-upload.tsx index a74aa0e..a8e7be9 100644 --- a/components/terminal/hooks/use-file-upload.tsx +++ b/components/terminal/hooks/use-file-upload.tsx @@ -100,21 +100,16 @@ export function useFileUpload(config: FileUploadConfig) { // Show initial toast if (options.showToast) { - uploadToastIdRef.current = toast.loading( - `Uploading ${files.length} file(s)...`, - { - description: 'Please wait', - } - ); + uploadToastIdRef.current = toast.loading(`Uploading ${files.length} file(s)...`, { + description: 'Please wait', + }); } try { // Dynamic import for tree-shaking - const { - uploadFilesToFileBrowser, - copyToClipboard, - formatFileSize, - } = await import('@/lib/utils/filebrowser'); + const { uploadFilesToFileBrowser, copyToClipboard, formatFileSize } = await import( + '@/lib/util/filebrowser' + ); // Upload with progress tracking const result = await uploadFilesToFileBrowser( @@ -148,40 +143,42 @@ export function useFileUpload(config: FileUploadConfig) { result.succeeded.length === 1 ? result.succeeded[0].path : result.rootPath; // Copy to clipboard + let clipboardSuccess = false; if (options.copyToClipboard) { try { await copyToClipboard(pathToCopy); + clipboardSuccess = true; } catch (error) { console.warn('[useFileUpload] Failed to copy to clipboard:', error); } } - // Show success toast + // Show success toast with clipboard feedback if (options.showToast) { if (result.failed.length === 0) { // All succeeded if (result.succeeded.length === 1) { const file = result.succeeded[0]; + const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; toast.success('File uploaded', { - description: `${file.filename} (${formatFileSize(file.size)}) • Path: ${pathToCopy}`, + description: `${file.filename} (${formatFileSize(file.size)}) • ${pathToCopy}${clipboardHint}`, duration: 5000, }); } else { + const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; toast.success(`${result.succeeded.length} files uploaded`, { - description: `Total: ${formatFileSize(totalSize)} • Path: ${pathToCopy}`, + description: `Total: ${formatFileSize(totalSize)} • ${pathToCopy}${clipboardHint}`, duration: 5000, }); } } else { // Partial success const failedNames = result.failed.map((f) => f.filename).join(', '); - toast.warning( - `Uploaded ${result.succeeded.length} of ${result.total} files`, - { - description: `${formatFileSize(totalSize)} uploaded • Failed: ${failedNames}`, - duration: 6000, - } - ); + const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; + toast.warning(`Uploaded ${result.succeeded.length} of ${result.total} files`, { + description: `${formatFileSize(totalSize)} uploaded • Failed: ${failedNames}${clipboardHint}`, + duration: 6000, + }); } } } else { @@ -218,12 +215,7 @@ export function useFileUpload(config: FileUploadConfig) { setUploadProgress(null); } }, - [ - isConfigured, - config.fileBrowserUrl, - config.fileBrowserUsername, - config.fileBrowserPassword, - ] + [isConfigured, config.fileBrowserUrl, config.fileBrowserUsername, config.fileBrowserPassword] ); return { @@ -232,4 +224,4 @@ export function useFileUpload(config: FileUploadConfig) { uploadProgress, isConfigured, }; -} \ No newline at end of file +} diff --git a/components/terminal/terminal-toolbar.tsx b/components/terminal/terminal-toolbar.tsx index f9a394c..60c79f0 100644 --- a/components/terminal/terminal-toolbar.tsx +++ b/components/terminal/terminal-toolbar.tsx @@ -73,11 +73,12 @@ export function TerminalToolbar({ const [showPassword, setShowPassword] = useState(false); const [copiedField, setCopiedField] = useState(null); - const networkEndpoints = [ - { domain: sandbox?.publicUrl || '', port: 3000, protocol: 'HTTPS', label: 'Application' }, - { domain: sandbox?.ttydUrl || '', port: 7681, protocol: 'HTTPS', label: 'Terminal' }, + // Build network endpoints list, filtering out any without URLs + const allEndpoints = [ + { domain: sandbox?.publicUrl, port: 3000, protocol: 'HTTPS', label: 'Application' }, + { domain: sandbox?.ttydUrl, port: 7681, protocol: 'HTTPS', label: 'Terminal' }, { - domain: sandbox?.fileBrowserUrl || '', + domain: sandbox?.fileBrowserUrl, port: 8080, protocol: 'HTTPS', label: 'File Browser', @@ -85,6 +86,9 @@ export function TerminalToolbar({ }, ]; + // Only show endpoints that have a valid domain URL + const networkEndpoints = allEndpoints.filter(endpoint => endpoint.domain); + const copyToClipboard = async (text: string, field: string) => { try { await navigator.clipboard.writeText(text); @@ -180,7 +184,7 @@ export function TerminalToolbar({ {endpoint.protocol}
{ + async (files: File[]) => { if (files.length === 0) return; - // Show quick info toast - toast.info(`${source === 'paste' ? 'Pasted' : 'Dropped'} ${files.length} file(s)`, { - description: 'Starting upload...', - duration: 2000, - }); - - // Upload in background + // Upload in background (uploadFiles will show toast with progress) try { await uploadFiles(files, { showToast: true, @@ -156,10 +148,10 @@ export function XtermTerminal({ ); // Setup drag and drop / paste event handling - const { isDragging } = useFileDrop({ + useFileDrop({ enabled: isConfigured && !isUploading, - onFilesDropped: (files) => handleFilesReceived(files, 'drop'), - onFilesPasted: (files) => handleFilesReceived(files, 'paste'), + onFilesDropped: handleFilesReceived, + onFilesPasted: handleFilesReceived, }); // ========================================================================= @@ -632,62 +624,6 @@ export function XtermTerminal({ )} - - {/* Subtle drag indicator - only shown when dragging files */} - {isConfigured && isDragging && ( -
- - Drop files to upload -
- )} - - {/* Upload progress indicator - shown as floating badge */} - {isUploading && ( -
-
- - - - -
- Uploading... -
- )}
); } diff --git a/lib/util/common-web.ts b/lib/util/common-web.ts new file mode 100644 index 0000000..af047cd --- /dev/null +++ b/lib/util/common-web.ts @@ -0,0 +1,58 @@ +/** + * Common Web Utilities + * + * Browser-specific utility functions that can be used across the application. + * These functions require browser APIs and cannot be used in server-side contexts. + */ + +// ============================================================================ +// Clipboard Utilities +// ============================================================================ + +/** + * Copy text to clipboard with fallback for older browsers + * + * Uses modern Clipboard API when available, with automatic fallback to + * deprecated execCommand for older browsers or security-restricted contexts. + * + * @param text - Text to copy to clipboard + * @throws Error if copy fails (only in fallback mode) + * + * @example + * ```typescript + * try { + * await copyToClipboard('/path/to/file'); + * console.log('Copied to clipboard!'); + * } catch (error) { + * console.error('Failed to copy:', error); + * } + * ``` + */ +export async function copyToClipboard(text: string): Promise { + // Try modern Clipboard API first (requires HTTPS or localhost) + try { + await navigator.clipboard.writeText(text) + return + } catch { + // Fallback for older browsers or security restrictions + } + + // Fallback: Create temporary textarea element + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' // Move out of viewport + textArea.style.top = '-999999px' + document.body.appendChild(textArea) + textArea.select() + + try { + // Note: document.execCommand is deprecated but needed for fallback + const successful = document.execCommand('copy') + if (!successful) { + throw new Error('execCommand returned false') + } + } finally { + document.body.removeChild(textArea) + } +} \ No newline at end of file diff --git a/lib/utils/filebrowser.ts b/lib/util/filebrowser.ts similarity index 90% rename from lib/utils/filebrowser.ts rename to lib/util/filebrowser.ts index 5b27f2c..b41fe59 100644 --- a/lib/utils/filebrowser.ts +++ b/lib/util/filebrowser.ts @@ -10,6 +10,8 @@ * 3. Files are uploaded to FileBrowser root directory (/) */ +export { copyToClipboard } from './common-web' + // ============================================================================ // Types // ============================================================================ @@ -296,38 +298,3 @@ export async function extractFilesFromDataTransfer(dataTransfer: DataTransfer): return files } - -// ============================================================================ -// Clipboard Utilities -// ============================================================================ - -/** - * Copy text to clipboard with fallback for older browsers - * - * @param text - Text to copy - * @throws Error if copy fails - */ -export async function copyToClipboard(text: string): Promise { - // Try modern Clipboard API first - try { - await navigator.clipboard.writeText(text) - return - } catch { - // Fallback for older browsers or security restrictions - } - - // Fallback: Create temporary textarea - const textArea = document.createElement('textarea') - textArea.value = text - textArea.style.position = 'fixed' - textArea.style.left = '-999999px' - document.body.appendChild(textArea) - textArea.select() - - try { - // Note: document.execCommand is deprecated but needed for fallback - document.execCommand('copy') - } finally { - document.body.removeChild(textArea) - } -} From 469b105e06f8fd06f182cdcf0b1e49a58de61abb Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 19 Nov 2025 12:40:22 +0000 Subject: [PATCH 4/5] chore --- app/api/sandbox/[id]/cwd/route.ts | 85 +++ app/test-upload/page.tsx | 267 -------- components/terminal/hooks/use-file-upload.tsx | 26 +- components/terminal/terminal-container.tsx | 1 + components/terminal/terminal-display.tsx | 3 + components/terminal/xterm-terminal.tsx | 126 +++- docs/technical-notes/TTYD_AUTHENTICATION.md | 610 ++++++++++++++++++ hooks/use-terminal.ts | 6 + lib/api-auth.ts | 46 ++ lib/k8s/kubernetes.ts | 19 + lib/k8s/sandbox-manager.ts | 126 ++++ lib/util/filebrowser.ts | 44 +- sandbox/ttyd-auth.sh | 22 +- 13 files changed, 1065 insertions(+), 316 deletions(-) create mode 100644 app/api/sandbox/[id]/cwd/route.ts delete mode 100644 app/test-upload/page.tsx create mode 100644 docs/technical-notes/TTYD_AUTHENTICATION.md diff --git a/app/api/sandbox/[id]/cwd/route.ts b/app/api/sandbox/[id]/cwd/route.ts new file mode 100644 index 0000000..f3f69e3 --- /dev/null +++ b/app/api/sandbox/[id]/cwd/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/sandbox/[id]/cwd + * + * Get current working directory of a terminal session in the sandbox. + * Uses session ID to identify the specific terminal instance. + * + * Query Parameters: + * - sessionId: Terminal session ID (from TERMINAL_SESSION_ID env var in shell) + * + * Returns: + * - cwd: Absolute path of current working directory + * - homeDir: User's home directory path + * - isInHome: Whether cwd is within homeDir + * + * Security: + * - Only allows file uploads within user's home directory + * - Uses /proc filesystem to find shell process by session ID + */ + +import { NextResponse } from 'next/server' + +import { verifySandboxAccess, withAuth } from '@/lib/api-auth' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { logger as baseLogger } from '@/lib/logger' + +const logger = baseLogger.child({ module: 'api/sandbox/[id]/cwd' }) + +interface CwdResponse { + cwd: string + homeDir: string + isInHome: boolean +} + +type GetCwdResponse = { error: string } | CwdResponse + +export const GET = withAuth(async (req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + try { + // Get sessionId from query params + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('sessionId') + + if (!sessionId) { + logger.warn(`Missing sessionId query parameter for sandbox ${sandboxId}`) + return NextResponse.json({ error: 'sessionId query parameter is required' }, { status: 400 }) + } + + // Verify user owns this sandbox + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + + logger.info( + `Getting current directory for sandbox ${sandboxId} (${sandbox.sandboxName}) with session ${sessionId}` + ) + + // Get K8s service for user + const k8sService = await getK8sServiceForUser(session.user.id) + + // Get current working directory from sandbox + const cwdInfo = await k8sService.getSandboxCurrentDirectory( + sandbox.k8sNamespace, + sandbox.sandboxName, + sessionId + ) + + logger.info( + `Current directory for sandbox ${sandboxId}: ${cwdInfo.cwd} (isInHome: ${cwdInfo.isInHome})` + ) + + return NextResponse.json({ + cwd: cwdInfo.cwd, + homeDir: cwdInfo.homeDir, + isInHome: cwdInfo.isInHome, + }) + } catch (error) { + logger.error(`Failed to get sandbox cwd: ${error}`) + + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json( + { error: `Failed to get current directory: ${errorMessage}` }, + { status: 500 } + ) + } +}) \ No newline at end of file diff --git a/app/test-upload/page.tsx b/app/test-upload/page.tsx deleted file mode 100644 index 21f0aa4..0000000 --- a/app/test-upload/page.tsx +++ /dev/null @@ -1,267 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -/** - * FileBrowser Upload Test Page - * - * Test page to debug file upload and authentication issues - */ -export default function TestUploadPage() { - const [result, setResult] = useState(''); - const [loading, setLoading] = useState(false); - - // Test configuration - const [config, setConfig] = useState({ - url: 'https://dd-xeuqsjxc-filebrowser.usw.sealos.io', - username: 'admin', - password: 'admin', - path: '/', - }); - - const handleFileSelect = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - setLoading(true); - setResult('Starting upload with JWT...\n'); - - try { - // Step 1: Login to get JWT token - setResult(prev => prev + `\n1. Logging in...\n`); - - const loginResponse = await fetch(`${config.url}/api/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: config.username, - password: config.password, - recaptcha: '', - }), - }); - - if (!loginResponse.ok) { - const errorText = await loginResponse.text(); - setResult(prev => prev + ` ❌ Login failed: ${errorText}\n`); - return; - } - - const token = await loginResponse.text(); - setResult(prev => prev + ` ✅ Got JWT token\n`); - - // Step 2: Try TUS upload (correct method) - setResult(prev => prev + `\n2. Uploading file via TUS...\n`); - setResult(prev => prev + ` File: ${file.name} (${file.size} bytes)\n`); - - // TUS: Create upload - const createUrl = `${config.url}/api/tus/${encodeURIComponent(file.name)}?override=false`; - setResult(prev => prev + ` Creating: ${createUrl}\n`); - - const createResponse = await fetch(createUrl, { - method: 'POST', - headers: { - 'X-Auth': token, - 'Upload-Length': file.size.toString(), - 'Tus-Resumable': '1.0.0', - }, - }); - - setResult(prev => prev + ` Create status: ${createResponse.status} ${createResponse.statusText}\n`); - - if (!createResponse.ok) { - const errorText = await createResponse.text(); - setResult(prev => prev + ` ❌ Create failed: ${errorText}\n`); - return; - } - - // TUS: Upload file content - const uploadUrl = `${config.url}/api/tus/${encodeURIComponent(file.name)}`; - setResult(prev => prev + `\n3. Uploading content...\n`); - - const uploadResponse = await fetch(uploadUrl, { - method: 'PATCH', - headers: { - 'X-Auth': token, - 'Content-Type': 'application/offset+octet-stream', - 'Upload-Offset': '0', - 'Tus-Resumable': '1.0.0', - }, - body: file, - }); - - setResult(prev => prev + ` Upload status: ${uploadResponse.status} ${uploadResponse.statusText}\n`); - - if (uploadResponse.ok) { - setResult(prev => prev + ` ✅ Upload successful!\n`); - setResult(prev => prev + `\n4. Verifying file exists...\n`); - - // Verify file exists - const verifyResponse = await fetch(`${config.url}/api/resources${config.path}`, { - method: 'GET', - headers: { - 'X-Auth': token, - }, - }); - - if (verifyResponse.ok) { - const data = await verifyResponse.json(); - const uploaded = data.items?.find((item: any) => item.name === file.name); - if (uploaded) { - setResult(prev => prev + ` ✅ File verified: ${uploaded.path}\n`); - } else { - setResult(prev => prev + ` ⚠️ File not found in listing\n`); - } - } - } else { - const errorText = await uploadResponse.text(); - setResult(prev => prev + ` ❌ Upload failed: ${errorText}\n`); - } - - } catch (error) { - setResult(prev => prev + `❌ Exception: ${error instanceof Error ? error.message : String(error)}\n`); - } finally { - setLoading(false); - } - }; - - const testAuth = async () => { - setLoading(true); - setResult('Testing JWT authentication...\n'); - - try { - // Step 1: Login to get JWT token - setResult(prev => prev + `\n1. Logging in to ${config.url}/api/login\n`); - - const loginResponse = await fetch(`${config.url}/api/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: config.username, - password: config.password, - recaptcha: '', - }), - }); - - setResult(prev => prev + ` Status: ${loginResponse.status} ${loginResponse.statusText}\n`); - - if (!loginResponse.ok) { - const errorText = await loginResponse.text(); - setResult(prev => prev + ` ❌ Login failed: ${errorText}\n`); - return; - } - - const token = await loginResponse.text(); - setResult(prev => prev + ` ✅ Got JWT token: ${token.substring(0, 50)}...\n`); - - // Step 2: Test API with JWT token - setResult(prev => prev + `\n2. Testing GET ${config.url}/api/resources/\n`); - - const response = await fetch(`${config.url}/api/resources/`, { - method: 'GET', - headers: { - 'X-Auth': token, - }, - }); - - setResult(prev => prev + ` Status: ${response.status} ${response.statusText}\n`); - - if (response.ok) { - const data = await response.json(); - setResult(prev => prev + ` ✅ API call successful!\n${JSON.stringify(data, null, 2)}\n`); - } else { - const errorText = await response.text(); - setResult(prev => prev + ` ❌ API call failed: ${errorText}\n`); - } - - } catch (error) { - setResult(prev => prev + `❌ Exception: ${error instanceof Error ? error.message : String(error)}\n`); - } finally { - setLoading(false); - } - }; - - return ( -
-

FileBrowser Upload Test

- - {/* Configuration */} -
-

Configuration

- -
- - setConfig(prev => ({ ...prev, url: e.target.value }))} - className="w-full px-3 py-2 border rounded" - placeholder="https://example.com" - /> -
- -
-
- - setConfig(prev => ({ ...prev, username: e.target.value }))} - className="w-full px-3 py-2 border rounded" - /> -
- -
- - setConfig(prev => ({ ...prev, password: e.target.value }))} - className="w-full px-3 py-2 border rounded" - /> -
-
- -
- - setConfig(prev => ({ ...prev, path: e.target.value }))} - className="w-full px-3 py-2 border rounded" - placeholder="/" - /> -
- -
- - - -
-
- - {/* Results */} -
-

Results

-
{result || 'No results yet...'}
-
-
- ); -} diff --git a/components/terminal/hooks/use-file-upload.tsx b/components/terminal/hooks/use-file-upload.tsx index a8e7be9..70e924f 100644 --- a/components/terminal/hooks/use-file-upload.tsx +++ b/components/terminal/hooks/use-file-upload.tsx @@ -44,6 +44,10 @@ export interface UploadOptions { showToast?: boolean; /** Copy path to clipboard after successful upload */ copyToClipboard?: boolean; + /** Target directory path for upload (defaults to root if not specified) */ + targetPath?: string; + /** Absolute container path for display in toast (e.g., /home/fulling/next/src) */ + absolutePath?: string; } // ============================================================================ @@ -127,7 +131,8 @@ export function useFileUpload(config: FileUploadConfig) { description: currentFile ? `Current: ${currentFile}` : 'Processing...', }); } - } + }, + options.targetPath // Pass target path to upload function ); // Dismiss loading toast @@ -139,8 +144,13 @@ export function useFileUpload(config: FileUploadConfig) { // Handle results if (result.succeeded.length > 0) { const totalSize = result.succeeded.reduce((sum, r) => sum + r.size, 0); + + // For clipboard: copy only filename (single file) or directory path (multiple files) const pathToCopy = - result.succeeded.length === 1 ? result.succeeded[0].path : result.rootPath; + result.succeeded.length === 1 ? result.succeeded[0].filename : result.rootPath; + + // For display: use absolute path if provided, otherwise use relative path + const displayPath = options.absolutePath || result.rootPath; // Copy to clipboard let clipboardSuccess = false; @@ -159,24 +169,24 @@ export function useFileUpload(config: FileUploadConfig) { // All succeeded if (result.succeeded.length === 1) { const file = result.succeeded[0]; - const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; + const clipboardHint = clipboardSuccess ? ' • Filename copied!' : ''; toast.success('File uploaded', { - description: `${file.filename} (${formatFileSize(file.size)}) • ${pathToCopy}${clipboardHint}`, + description: `${file.filename} (${formatFileSize(file.size)}) → ${displayPath}${clipboardHint}`, duration: 5000, }); } else { - const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; + const clipboardHint = clipboardSuccess ? ' • Directory path copied!' : ''; toast.success(`${result.succeeded.length} files uploaded`, { - description: `Total: ${formatFileSize(totalSize)} • ${pathToCopy}${clipboardHint}`, + description: `Total: ${formatFileSize(totalSize)} → ${displayPath}${clipboardHint}`, duration: 5000, }); } } else { // Partial success const failedNames = result.failed.map((f) => f.filename).join(', '); - const clipboardHint = clipboardSuccess ? ' • Path copied!' : ''; + const clipboardHint = clipboardSuccess ? ' • Directory path copied!' : ''; toast.warning(`Uploaded ${result.succeeded.length} of ${result.total} files`, { - description: `${formatFileSize(totalSize)} uploaded • Failed: ${failedNames}${clipboardHint}`, + description: `${formatFileSize(totalSize)} uploaded → ${displayPath} • Failed: ${failedNames}${clipboardHint}`, duration: 6000, }); } diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index a0a051f..8b1c9c9 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -137,6 +137,7 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) {/* Each tab maintains its own terminal instance */} (null); + const fileDropContainerRef = useRef(null); // Wrapper for file drop events + const containerRef = useRef(null); // Xterm.js container const terminalRef = useRef(null); const hasNewContentRef = useRef(false); const newLineCountRef = useRef(0); @@ -117,6 +130,9 @@ export function XtermTerminal({ const [hasNewContent, setHasNewContent] = useState(false); const [newLineCount, setNewLineCount] = useState(0); + // Terminal session ID for multi-terminal support + const terminalSessionId = useRef(`terminal-${Date.now()}-${Math.random().toString(36).slice(2)}`); + // ========================================================================= // File Upload Integration // ========================================================================= @@ -134,24 +150,71 @@ export function XtermTerminal({ async (files: File[]) => { if (files.length === 0) return; - // Upload in background (uploadFiles will show toast with progress) + // Get current working directory from sandbox + let targetPath: string | undefined = undefined; + let absolutePath: string | undefined = undefined; + + try { + const response = await fetch( + `/api/sandbox/${sandboxId}/cwd?sessionId=${terminalSessionId.current}` + ); + + if (response.ok) { + const cwdInfo = await response.json(); + + // Check if current directory is within home directory + if (cwdInfo.isInHome && cwdInfo.cwd && cwdInfo.homeDir) { + // Convert container absolute path to FileBrowser relative path + // FileBrowser root (/srv) is mounted to /home/fulling + // Example: /home/fulling/next/src -> /next/src + const relativePath = cwdInfo.cwd.startsWith(cwdInfo.homeDir) + ? cwdInfo.cwd.slice(cwdInfo.homeDir.length) || '/' + : '/'; + + targetPath = relativePath; + absolutePath = cwdInfo.cwd; // Store absolute path for toast display + } else if (!cwdInfo.isInHome) { + toast.warning('Upload to home directory', { + description: `Current directory (${cwdInfo.cwd}) is outside home. Uploading to home directory instead.`, + duration: 4000, + }); + // Will use default path (/) with homeDir as absolute path + absolutePath = cwdInfo.homeDir; + } + } else { + console.warn('[XtermTerminal] Failed to get cwd, using default upload path'); + } + } catch (error) { + console.warn('[XtermTerminal] Failed to get cwd:', error); + // Continue with default path + } + + // Upload files - if targetPath is undefined, uploadFiles will use default root path try { await uploadFiles(files, { showToast: true, copyToClipboard: true, + targetPath: targetPath, // undefined = use default root path + absolutePath: absolutePath, // Absolute container path for toast display }); } catch (error) { console.error('[XtermTerminal] Upload failed:', error); + toast.error('Upload failed', { + description: error instanceof Error ? error.message : 'Unknown error', + duration: 5000, + }); } }, - [uploadFiles] + [uploadFiles, sandboxId] ); // Setup drag and drop / paste event handling + // Listen on terminal container element instead of window for proper multi-terminal isolation useFileDrop({ enabled: isConfigured && !isUploading, onFilesDropped: handleFilesReceived, onFilesPasted: handleFilesReceived, + containerRef: fileDropContainerRef, // Listen on this terminal's container only }); // ========================================================================= @@ -200,7 +263,6 @@ export function XtermTerminal({ const terminal = terminalRef.current; if (terminal) { terminal.scrollToBottom(); - console.log('[XtermTerminal] User scrolled to bottom'); } setHasNewContent(false); setNewLineCount(0); @@ -254,10 +316,10 @@ export function XtermTerminal({ }; // ----------------------------------------------------------------------- - // Helper: Parse WebSocket URL + // Helper: Parse WebSocket URL and add session ID // ----------------------------------------------------------------------- - const parseUrl = (): { wsFullUrl: string; token: string } | null => { + const parseUrl = (): string | null => { try { const url = new URL(wsUrl); const token = url.searchParams.get('arg') || ''; @@ -267,12 +329,16 @@ export function XtermTerminal({ return null; } + // Add session ID as second arg parameter for ttyd-auth.sh + // URL format: ?arg=TOKEN&arg=SESSION_ID + url.searchParams.append('arg', terminalSessionId.current); + const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; const wsPath = url.pathname.replace(/\/$/, '') + '/ws'; const wsFullUrl = `${wsProtocol}//${url.host}${wsPath}${url.search}`; console.log('[XtermTerminal] Connecting to:', wsFullUrl.replace(token, '***')); - return { wsFullUrl, token }; + return wsFullUrl; } catch (error) { console.error('[XtermTerminal] Failed to parse URL:', error); return null; @@ -354,14 +420,12 @@ export function XtermTerminal({ const connectWebSocket = () => { if (!terminal || !isMounted) return; - const urlInfo = parseUrl(); - if (!urlInfo) { + const wsFullUrl = parseUrl(); + if (!wsFullUrl) { stableOnDisconnected(); return; } - const { wsFullUrl, token } = urlInfo; - console.log('[XtermTerminal] Creating WebSocket connection...'); socket = new WebSocket(wsFullUrl, ['tty']); socket.binaryType = 'arraybuffer'; @@ -371,12 +435,16 @@ export function XtermTerminal({ console.log('[XtermTerminal] WebSocket connected'); stableOnConnected(); - const authMsg = JSON.stringify({ - AuthToken: token, + // Send initial terminal size to ttyd + // Note: AuthToken field removed - this project uses shell script authentication + // instead of ttyd's built-in WebSocket authentication (server->credential = NULL) + // See: docs/technical-notes/TTYD_AUTHENTICATION.md + const initMsg = JSON.stringify({ columns: terminal!.cols, rows: terminal!.rows, }); - socket?.send(textEncoder.encode(authMsg)); + socket?.send(textEncoder.encode(initMsg)); + terminal!.focus(); }; @@ -587,7 +655,7 @@ export function XtermTerminal({ // ========================================================================= return ( -
+
{/* Scroll to bottom button */} diff --git a/docs/technical-notes/TTYD_AUTHENTICATION.md b/docs/technical-notes/TTYD_AUTHENTICATION.md new file mode 100644 index 0000000..ebc48da --- /dev/null +++ b/docs/technical-notes/TTYD_AUTHENTICATION.md @@ -0,0 +1,610 @@ +# ttyd Authentication Architecture + +## Overview + +This document explains the authentication mechanism used in FullstackAgent's terminal system and why it differs from ttyd's built-in authentication. + +## Table of Contents + +1. [Authentication Layers](#authentication-layers) +2. [ttyd's Built-in Authentication](#ttyds-built-in-authentication) +3. [FullstackAgent's Authentication](#fullstackagents-authentication) +4. [Why We Don't Use AuthToken](#why-we-dont-use-authtoken) +5. [Security Analysis](#security-analysis) +6. [Implementation Details](#implementation-details) + +--- + +## Authentication Layers + +ttyd supports three authentication layers: + +| Layer | Purpose | Implementation | Status in FullstackAgent | +|-------|---------|----------------|--------------------------| +| **Layer 1: HTTP Authentication** | Protect WebSocket handshake | HTTP Basic Auth / Custom Header | ❌ Not Used | +| **Layer 2: WebSocket Authentication** | Validate first WebSocket message | `AuthToken` field in JSON message | ❌ Not Used | +| **Layer 3: Shell Script Authentication** | Validate before spawning shell | Custom shell script (ttyd-auth.sh) | ✅ **Used** | + +--- + +## ttyd's Built-in Authentication + +### How ttyd's `-c` Parameter Works + +```bash +# Start ttyd with HTTP Basic Auth +ttyd -c "username:password" /bin/bash +``` + +**Internal Processing:** +1. ttyd Base64 encodes `username:password` +2. Stores result in `server->credential` +3. Validates at two levels: + - **HTTP Layer**: Checks `Authorization` header during WebSocket handshake + - **WebSocket Layer**: Checks `AuthToken` field in first JSON message + +### WebSocket Authentication Message (ttyd Original Design) + +```json +{ + "AuthToken": "Base64(username:password)", + "columns": 80, + "rows": 24 +} +``` + +**Validation Code (ttyd source):** +```c +// protocol.c +case JSON_DATA: + if (server->credential != NULL) { + struct json_object *o = NULL; + if (json_object_object_get_ex(obj, "AuthToken", &o)) { + const char *token = json_object_get_string(o); + if (token != NULL && !strcmp(token, server->credential)) + pss->authenticated = true; + else + lwsl_warn("WS authentication failed with token: %s\n", token); + } + if (!pss->authenticated) { + lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION, NULL, 0); + return -1; // Close WebSocket + } + } + spawn_process(pss, columns, rows); + break; +``` + +--- + +## FullstackAgent's Authentication + +### Design Decisions + +**Why Shell Script Authentication?** + +1. ✅ **Single Token Simplicity** - No need for `username:password` format +2. ✅ **Environment Variable Security** - Token passed via Kubernetes Secrets +3. ✅ **Multi-Parameter Support** - Can pass SESSION_ID alongside token +4. ✅ **Flexibility** - Custom validation logic in bash script +5. ✅ **Process Isolation** - Works with Kubernetes exec constraints + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend: Generate Session ID │ +├─────────────────────────────────────────────────────────────┤ +│ terminalSessionId = `terminal-${Date.now()}-${random()}` │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ WebSocket URL: Pass token and session ID as URL params │ +├─────────────────────────────────────────────────────────────┤ +│ wss://terminal.example.com/ws?arg=TOKEN&arg=SESSION_ID │ +│ ^^^^^ ^^^^^^^^^^ │ +│ $1 $2 │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ WebSocket Message: Send terminal size (NO AuthToken) │ +├─────────────────────────────────────────────────────────────┤ +│ { │ +│ "columns": 80, │ +│ "rows": 24 │ +│ } │ +│ │ +│ Note: AuthToken field removed because: │ +│ - ttyd started without -c parameter │ +│ - server->credential = NULL │ +│ - WebSocket authentication not used │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ttyd: Trigger spawn_process() │ +├─────────────────────────────────────────────────────────────┤ +│ ttyd receives JSON message: │ +│ - Extracts columns and rows │ +│ - Skips AuthToken validation (credential = NULL) │ +│ - Calls spawn_process(pss, columns, rows) │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ttyd: Build command with URL arguments │ +├─────────────────────────────────────────────────────────────┤ +│ Command: /usr/local/bin/ttyd-auth.sh TOKEN SESSION_ID │ +│ ^^^^^ ^^^^^^^^^^ │ +│ $1 $2 │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Shell Script: ttyd-auth.sh performs authentication │ +├─────────────────────────────────────────────────────────────┤ +│ #!/bin/bash │ +│ EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" │ +│ PROVIDED_TOKEN="$1" │ +│ │ +│ if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then │ +│ echo "ERROR: Authentication failed" │ +│ sleep infinity # Block shell startup │ +│ fi │ +│ │ +│ # Optional: Store session PID │ +│ if [ -n "$2" ]; then │ +│ SESSION_ID="$2" │ +│ echo "$$" > "/tmp/.terminal-session-${SESSION_ID}" │ +│ fi │ +│ │ +│ exec /bin/bash # Start shell │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Why We Don't Use AuthToken + +### Technical Reasons + +#### 1. **ttyd Started Without `-c` Parameter** + +```bash +# sandbox/entrypoint.sh +ttyd -T xterm-256color -W -a -t "$THEME" /usr/local/bin/ttyd-auth.sh +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# Authentication delegated to script +# ^^ -a: Allow URL arguments +# No -c parameter → server->credential = NULL +``` + +**Result:** +- `server->credential = NULL` +- WebSocket authentication code skipped +- `AuthToken` field in JSON message is ignored + +#### 2. **JSON Message Purpose** + +The JSON message sent after WebSocket connection serves **three purposes**: + +| Purpose | Required? | FullstackAgent Usage | +|---------|-----------|----------------------| +| Trigger `spawn_process()` | ✅ Required | ✅ Used | +| Initialize terminal size | ✅ Required | ✅ Used | +| Authenticate via AuthToken | ⚠️ Optional (if `-c` used) | ❌ Not Used | + +**Code Evidence (ttyd protocol.c):** +```c +case JSON_DATA: + if (pss->process != NULL) break; + + // Extract terminal size (REQUIRED) + uint16_t columns = 0, rows = 0; + json_object *obj = parse_window_size(pss->buffer, pss->len, &columns, &rows); + + // AuthToken validation (ONLY if server->credential != NULL) + if (server->credential != NULL) { + // ... validation code ... + } + + // Spawn process (REQUIRED for shell startup) + spawn_process(pss, columns, rows); + break; +``` + +**Without JSON Message:** +- ❌ `spawn_process()` never called +- ❌ Shell never starts +- ❌ Terminal hangs forever + +**With JSON Message (no AuthToken):** +- ✅ `spawn_process()` called +- ✅ Shell script receives URL arguments +- ✅ Script validates token from environment variable + +#### 3. **Authentication Happens in Shell Script** + +```bash +# sandbox/ttyd-auth.sh + +# Environment variable set by Kubernetes +EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" + +# Provided via URL (?arg=TOKEN) +PROVIDED_TOKEN="$1" + +# Validation +if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then + echo "ERROR: Authentication failed - invalid token" + sleep infinity # Block indefinitely (no shell access) +fi + +# Success +echo "✓ Authentication successful" +exec /bin/bash +``` + +**Advantages:** +- 🔒 Token never appears in WebSocket messages +- 🔒 Token stored in Kubernetes Secrets (environment variable) +- 🔒 Validation happens at OS level (bash script) +- 🔒 Failed authentication blocks shell startup + +--- + +## Security Analysis + +### Comparison: WebSocket Auth vs Shell Script Auth + +| Aspect | ttyd WebSocket Auth | FullstackAgent Shell Auth | +|--------|---------------------|---------------------------| +| **Token Storage** | Command line (`-c` parameter) | Environment variable (Kubernetes Secret) | +| **Token Visibility** | Visible in process list | Hidden (injected at runtime) | +| **Token Format** | `Base64(username:password)` | Any string (32+ random chars) | +| **Transmission** | WebSocket message (encrypted) | URL parameter + Script parameter | +| **Validation Point** | ttyd server (Layer 2) | Shell script (Layer 3) | +| **Multi-tenancy** | Single credential for all users | Per-project unique tokens | +| **Session Tracking** | Not supported | Supported (SESSION_ID) | + +### Security Features in FullstackAgent + +#### 1. **Token Generation** +```typescript +// app/api/projects/route.ts +const ttydAuthToken = generateRandomString(32); +// Example: "7a9f2e8d3c1b5a4e6f0d8c2a1b3e5f7a" +``` + +#### 2. **Token Storage** +```typescript +// Stored in database with encryption +const environment = await tx.environment.create({ + data: { + projectId: project.id, + key: 'TTYD_ACCESS_TOKEN', + value: ttydAuthToken, + category: EnvironmentCategory.TTYD, + isSecret: true, // Marked as secret + }, +}); +``` + +#### 3. **Token Injection** +```typescript +// lib/events/sandbox/sandboxListener.ts +const projectEnvVars = await getProjectEnvironments(project.id); +// Includes TTYD_ACCESS_TOKEN + +const sandboxInfo = await k8sService.createSandbox( + project.name, + sandbox.k8sNamespace, + sandbox.sandboxName, + projectEnvVars // Injected into Kubernetes StatefulSet +); +``` + +#### 4. **Token Validation** +```bash +# sandbox/ttyd-auth.sh +# Runs inside container with environment variable injected by Kubernetes +EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" +PROVIDED_TOKEN="$1" + +if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then + sleep infinity # No shell access +fi +``` + +### Attack Surface Analysis + +| Attack Vector | ttyd WebSocket Auth | FullstackAgent Shell Auth | +|---------------|---------------------|---------------------------| +| **Process list exposure** | ⚠️ Token visible in `ps aux` | ✅ Token in environment (not visible) | +| **WebSocket intercept** | ⚠️ Token in WebSocket message | ✅ Token not in WebSocket message | +| **Replay attack** | ⚠️ Captured token can be reused | ⚠️ Captured token can be reused* | +| **Brute force** | ⚠️ Same token for all sessions | ✅ Per-project unique tokens | +| **Token rotation** | ❌ Requires ttyd restart | ✅ Update DB + restart pod | + +*Note: Replay attacks mitigated by: +- Short-lived tokens (can implement expiration) +- Network-level security (HTTPS/WSS) +- Kubernetes network policies + +--- + +## Implementation Details + +### Frontend: Terminal Connection + +**File:** `components/terminal/xterm-terminal.tsx` + +```typescript +// Generate unique session ID per terminal instance +const terminalSessionId = useRef( + `terminal-${Date.now()}-${Math.random().toString(36).slice(2)}` +); + +// Parse URL and add session ID +const parseUrl = (): { wsFullUrl: string; token: string } | null => { + const url = new URL(wsUrl); + const token = url.searchParams.get('arg') || ''; + + if (!token) { + console.error('[XtermTerminal] No authentication token found in URL'); + return null; + } + + // Add session ID as second arg parameter + url.searchParams.append('arg', terminalSessionId.current); + + const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsPath = url.pathname.replace(/\/$/, '') + '/ws'; + const wsFullUrl = `${wsProtocol}//${url.host}${wsPath}${url.search}`; + + return { wsFullUrl, token }; +}; + +// WebSocket connection +socket.onopen = () => { + // Send terminal size (AuthToken field removed) + const initMsg = JSON.stringify({ + columns: terminal!.cols, + rows: terminal!.rows, + }); + socket?.send(textEncoder.encode(initMsg)); +}; +``` + +### Backend: Token Management + +**File:** `app/api/projects/route.ts` + +```typescript +// Create project with TTYD_ACCESS_TOKEN +const ttydAuthToken = generateRandomString(32); + +const environment = await tx.environment.create({ + data: { + projectId: project.id, + key: 'TTYD_ACCESS_TOKEN', + value: ttydAuthToken, + category: EnvironmentCategory.TTYD, + isSecret: true, + }, +}); +``` + +**File:** `lib/events/sandbox/sandboxListener.ts` + +```typescript +async function handleCreateSandbox(payload: SandboxEventPayload) { + // Load environment variables (includes TTYD_ACCESS_TOKEN) + const projectEnvVars = await getProjectEnvironments(project.id); + + // Inject into Kubernetes StatefulSet + const sandboxInfo = await k8sService.createSandbox( + project.name, + sandbox.k8sNamespace, + sandbox.sandboxName, + projectEnvVars + ); +} +``` + +### Container: Authentication Script + +**File:** `sandbox/ttyd-auth.sh` + +```bash +#!/bin/bash +# ttyd authentication wrapper script +# Validates TTYD_ACCESS_TOKEN before granting shell access +# +# Arguments (passed via URL ?arg=...&arg=...): +# $1 - TTYD_ACCESS_TOKEN (required) +# $2 - TERMINAL_SESSION_ID (optional, for file upload directory tracking) + +# Get expected token from environment variable (injected by Kubernetes) +EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" + +# Check if token is configured +if [ -z "$EXPECTED_TOKEN" ]; then + echo "ERROR: TTYD_ACCESS_TOKEN is not configured" + echo "Please contact your system administrator" + sleep infinity +fi + +# Check if token was provided as argument +if [ "$#" -lt 1 ]; then + echo "ERROR: Authentication failed - no token provided" + sleep infinity +fi + +PROVIDED_TOKEN="$1" + +# Validate token +if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then + echo "ERROR: Authentication failed - invalid token" + sleep infinity +fi + +# Authentication successful +echo "✓ Authentication successful" + +# Optional: Handle terminal session ID for file upload directory tracking +if [ "$#" -ge 2 ] && [ -n "$2" ]; then + TERMINAL_SESSION_ID="$2" + export TERMINAL_SESSION_ID + + # Store shell PID in session file + SESSION_FILE="/tmp/.terminal-session-${TERMINAL_SESSION_ID}" + echo "$$" > "$SESSION_FILE" + + echo "✓ Terminal session: ${TERMINAL_SESSION_ID}" +fi + +# Start bash shell +exec /bin/bash +``` + +**File:** `sandbox/entrypoint.sh` + +```bash +#!/bin/bash +# Start ttyd with authentication wrapper + +ttyd -T xterm-256color -W -a -t "$THEME" /usr/local/bin/ttyd-auth.sh +# ^^ ^^ ^^ +# | | | +# | | +-- Allow URL arguments (?arg=...) +# | +-- Allow client writes +# +-- Terminal type +# +# Note: No -c parameter → server->credential = NULL +# Authentication delegated to ttyd-auth.sh script +``` + +--- + +## Environment Variable Requirements + +### Required Environment Variables + +| Variable | Purpose | Set By | Example Value | +|----------|---------|--------|---------------| +| `TTYD_ACCESS_TOKEN` | Authentication token | Kubernetes (from DB) | `7a9f2e8d3c1b5a4e6f0d8c2a1b3e5f7a` | +| `TERMINAL_SESSION_ID` | Session tracking (optional) | ttyd-auth.sh (from URL) | `terminal-1234567890-abc123` | + +### Environment Variable Flow + +``` +Database + ↓ +Environment table (key='TTYD_ACCESS_TOKEN', isSecret=true) + ↓ +Kubernetes StatefulSet (env vars) + ↓ +Container process (environment variable) + ↓ +ttyd-auth.sh reads $TTYD_ACCESS_TOKEN + ↓ +Compares with $1 (from URL ?arg=TOKEN) +``` + +--- + +## Troubleshooting + +### Issue: Terminal hangs on connection + +**Symptoms:** +- WebSocket connects successfully +- No shell prompt appears +- Terminal stuck in loading state + +**Cause:** +- `TTYD_ACCESS_TOKEN` environment variable not set in container +- Token mismatch between URL and environment variable + +**Solution:** +```bash +# Check environment variable in container +kubectl exec -it -n -- env | grep TTYD_ACCESS_TOKEN + +# Check URL token +# Should match the value in database Environment table +``` + +### Issue: Authentication failed error + +**Symptoms:** +- Error message: "ERROR: Authentication failed - invalid token" +- Shell never starts + +**Cause:** +- Token in URL doesn't match `TTYD_ACCESS_TOKEN` environment variable + +**Solution:** +1. Verify token in database: + ```sql + SELECT value FROM Environment + WHERE key = 'TTYD_ACCESS_TOKEN' AND projectId = ''; + ``` + +2. Verify token in URL: + ``` + ttydUrl: "https://sandbox-ttyd.example.com?arg=&arg=" + ``` + +3. Ensure they match + +### Issue: Session tracking not working + +**Symptoms:** +- File uploads go to root directory instead of current directory +- `/tmp/.terminal-session-*` file not found + +**Cause:** +- SESSION_ID not passed in URL +- ttyd-auth.sh not storing PID + +**Solution:** +1. Check URL has session ID: + ``` + ?arg=&arg= + ``` + +2. Verify session file created: + ```bash + kubectl exec -it -n -- ls -la /tmp/.terminal-session-* + ``` + +--- + +## References + +- [ttyd GitHub Repository](https://github.com/tsl0922/ttyd) +- [ttyd Protocol Documentation](https://github.com/tsl0922/ttyd/blob/main/docs/protocol.md) +- [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) +- [FullstackAgent Architecture](./TECHNICAL_DOCUMENTATION.md) + +--- + +## Changelog + +- **2025-01-19**: Initial documentation +- **2025-01-19**: Removed AuthToken field from WebSocket message (not needed) + +--- + +## Related Files + +- `components/terminal/xterm-terminal.tsx` - Frontend terminal component +- `sandbox/ttyd-auth.sh` - Authentication script +- `sandbox/entrypoint.sh` - Container entrypoint +- `app/api/projects/route.ts` - Token generation +- `lib/events/sandbox/sandboxListener.ts` - Token injection +- `lib/k8s/sandbox-manager.ts` - Kubernetes resource management diff --git a/hooks/use-terminal.ts b/hooks/use-terminal.ts index feff195..a9b7d8c 100644 --- a/hooks/use-terminal.ts +++ b/hooks/use-terminal.ts @@ -34,6 +34,10 @@ export interface UseTerminalReturn { reconnect: () => void /** Disconnect and stop auto-reconnect */ disconnect: () => void + /** Callback to handle connected event from XtermTerminal */ + handleConnected: () => void + /** Callback to handle disconnected event from XtermTerminal */ + handleDisconnected: () => void } export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { @@ -138,5 +142,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { wsUrl, reconnect, disconnect, + handleConnected, + handleDisconnected, } } diff --git a/lib/api-auth.ts b/lib/api-auth.ts index f4f261e..9e8031b 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -253,3 +253,49 @@ export async function verifyProjectAccessWithRelations(projectId: string, userId logger.debug(`Project access granted (with relations): projectId=${projectId}, userId=${userId}`) return project } + +/** + * Business logic helper: Verify user owns a sandbox + * + * This verifies that the user has access to the sandbox (through project ownership). + * + * Example usage: + * ```typescript + * export const GET = withAuth(async (req, context, session) => { + * const { id } = await context.params + * + * // Check if user owns this sandbox + * const sandbox = await verifySandboxAccess(id, session.user.id) + * + * // sandbox is guaranteed to exist and belong to the user + * return NextResponse.json(sandbox) + * }) + * ``` + * + * @param sandboxId - Sandbox ID to verify + * @param userId - User ID from session + * @returns Sandbox if user has access + * @throws NextResponse with 404 if sandbox not found or access denied + */ +export async function verifySandboxAccess(sandboxId: string, userId: string) { + const sandbox = await prisma.sandbox.findFirst({ + where: { + id: sandboxId, + }, + include: { + project: { + select: { + userId: true, + }, + }, + }, + }) + + if (!sandbox || sandbox.project.userId !== userId) { + logger.warn(`Sandbox access denied: sandboxId=${sandboxId}, userId=${userId}`) + throw NextResponse.json({ error: 'Sandbox not found' }, { status: 404 }) + } + + logger.debug(`Sandbox access granted: sandboxId=${sandboxId}, userId=${userId}`) + return sandbox +} diff --git a/lib/k8s/kubernetes.ts b/lib/k8s/kubernetes.ts index afb7f67..37666ca 100644 --- a/lib/k8s/kubernetes.ts +++ b/lib/k8s/kubernetes.ts @@ -256,6 +256,25 @@ export class KubernetesService { getNamespaceFromKubeConfig(): string { return KubernetesUtils.getNamespaceFromKubeConfig(this.kc) } + + /** + * Get current working directory of a terminal session in sandbox + * + * Delegates to SandboxManager for execution + * + * @param namespace - Kubernetes namespace + * @param sandboxName - Sandbox StatefulSet name + * @param sessionId - Terminal session ID (from TERMINAL_SESSION_ID env var) + * @returns Current working directory info + */ + async getSandboxCurrentDirectory( + namespace: string, + sandboxName: string, + sessionId: string + ): Promise<{ cwd: string; homeDir: string; isInHome: boolean }> { + namespace = namespace || this.defaultNamespace + return await this.sandboxManager.getSandboxCurrentDirectory(namespace, sandboxName, sessionId) + } } // Re-export types for convenience diff --git a/lib/k8s/sandbox-manager.ts b/lib/k8s/sandbox-manager.ts index 61288fe..9cc599b 100644 --- a/lib/k8s/sandbox-manager.ts +++ b/lib/k8s/sandbox-manager.ts @@ -1410,4 +1410,130 @@ echo "=== Init Container: Completed successfully ===" // The PVCs might have already been deleted by K8s retention policy } } + + /** + * Get current working directory of a terminal session in sandbox + * + * Uses session ID to find the shell process via proc filesystem environ + * and reads its working directory from proc pid cwd + * + * @param namespace - Kubernetes namespace + * @param sandboxName - Sandbox StatefulSet name + * @param sessionId - Terminal session ID (from TERMINAL_SESSION_ID env var) + * @returns Current working directory info + */ + async getSandboxCurrentDirectory( + namespace: string, + sandboxName: string, + sessionId: string + ): Promise<{ cwd: string; homeDir: string; isInHome: boolean }> { + const exec = new k8s.Exec(this.kc) + const podName = `${sandboxName}-0` // StatefulSet pod naming + + // Combined script: Find PID from session file and get working directory + // We store the shell PID in a file because K8s exec creates a new shell + // that can't see the environment variables of the ttyd shell + const combinedScript = ` +#!/bin/bash +# Find shell process by reading PID from session file + +# Step 1: Read shell PID from session file +SESSION_FILE="/tmp/.terminal-session-${sessionId}" +if [ ! -f "$SESSION_FILE" ]; then + echo "ERROR: Session file not found: $SESSION_FILE" >&2 + echo "HINT: Make sure the terminal has fully loaded" >&2 + exit 1 +fi + +SHELL_PID=$(cat "$SESSION_FILE" 2>/dev/null) +if [ -z "$SHELL_PID" ]; then + echo "ERROR: Failed to read PID from session file: $SESSION_FILE" >&2 + exit 1 +fi + +# Verify the process exists +if [ ! -d "/proc/$SHELL_PID" ]; then + echo "ERROR: Process $SHELL_PID no longer exists" >&2 + echo "HINT: The terminal session may have been closed" >&2 + exit 1 +fi + +# Step 2: Get current working directory +CWD=$(readlink -f /proc/$SHELL_PID/cwd 2>/dev/null) +if [ $? -ne 0 ] || [ -z "$CWD" ]; then + echo "ERROR: Failed to read current directory for PID $SHELL_PID" >&2 + exit 1 +fi + +# Get home directory +HOME_DIR=$(eval echo ~$(stat -c '%U' /proc/$SHELL_PID 2>/dev/null)) +if [ -z "$HOME_DIR" ]; then + HOME_DIR="/home/agent" # Fallback to default +fi + +# Check if CWD is within HOME_DIR +if [[ "$CWD" == "$HOME_DIR"* ]]; then + IS_IN_HOME="true" +else + IS_IN_HOME="false" +fi + +# Output JSON +echo "{\\"cwd\\":\\"$CWD\\",\\"homeDir\\":\\"$HOME_DIR\\",\\"isInHome\\":$IS_IN_HOME}" +` + + let output = '' + let errorOutput = '' + + try { + const stream = await import('stream') + + await new Promise((resolve, reject) => { + const stdoutStream = new stream.PassThrough() + const stderrStream = new stream.PassThrough() + + stdoutStream.on('data', (chunk) => { + output += chunk.toString() + }) + + stderrStream.on('data', (chunk) => { + errorOutput += chunk.toString() + }) + + exec.exec( + namespace, + podName, + sandboxName, // Use sandboxName as container name (matches StatefulSet definition) + ['bash', '-c', combinedScript], + stdoutStream, + stderrStream, + null, + false, + (status) => { + if (status.status === 'Success') { + resolve() + } else { + reject(new Error(`Command failed: ${status.message || errorOutput}`)) + } + } + ) + }) + } catch (error) { + throw new Error(`Failed to get current directory: ${error} - ${errorOutput}`) + } + + // Parse JSON output + try { + const result = JSON.parse(output.trim()) + return { + cwd: result.cwd, + homeDir: result.homeDir, + isInHome: result.isInHome, + } + } catch (parseError) { + throw new Error( + `Failed to parse directory info. Output: ${output}, Parse Error: ${parseError}, Stderr: ${errorOutput}` + ) + } + } } diff --git a/lib/util/filebrowser.ts b/lib/util/filebrowser.ts index b41fe59..979a042 100644 --- a/lib/util/filebrowser.ts +++ b/lib/util/filebrowser.ts @@ -88,27 +88,44 @@ export async function loginToFileBrowser( * Upload a single file to FileBrowser using TUS protocol * * TUS (Tus Resumable Upload) protocol: - * - POST /api/tus/{filename}?override=false - Create upload session - * - PATCH /api/tus/{filename} - Upload file content in chunks + * - POST /api/tus/{path}/{filename}?override=false - Create upload session + * - PATCH /api/tus/{path}/{filename} - Upload file content in chunks * * @param fileBrowserUrl - Base URL of FileBrowser * @param token - JWT token from loginToFileBrowser() * @param file - File to upload + * @param targetPath - Target directory path (e.g., "/home/fulling/next", default: "/") * @returns Upload result with file path and metadata * @throws Error if upload fails */ export async function uploadFileToFileBrowser( fileBrowserUrl: string, token: string, - file: File + file: File, + targetPath: string = '/' ): Promise { // Dynamic import for client-side only (tus-js-client uses browser APIs) const tus = await import('tus-js-client') - // FileBrowser TUS endpoint format: /api/tus/{filename}?override=false - // Filename must be in URL, not in metadata + // Normalize target path: remove trailing slash, ensure leading slash + let normalizedPath = targetPath.trim() + if (normalizedPath !== '/' && normalizedPath.endsWith('/')) { + normalizedPath = normalizedPath.slice(0, -1) + } + if (!normalizedPath.startsWith('/')) { + normalizedPath = '/' + normalizedPath + } + + // FileBrowser TUS endpoint format: /api/tus/{path}/{filename}?override=false + // Filename and path must be in URL, not in metadata const encodedFilename = encodeURIComponent(file.name) - const tusEndpoint = `${fileBrowserUrl}/api/tus/${encodedFilename}?override=false` + const encodedPath = normalizedPath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') + + // Build TUS endpoint with path + const tusEndpoint = `${fileBrowserUrl}/api/tus${encodedPath}/${encodedFilename}?override=false` return new Promise((resolve, reject) => { const upload = new tus.Upload(file, { @@ -122,8 +139,8 @@ export async function uploadFileToFileBrowser( reject(new Error(`Upload failed: ${error.message}`)) }, onSuccess: () => { - // FileBrowser uploads to root directory - const uploadedPath = `/${file.name}` + // FileBrowser uploads to specified directory + const uploadedPath = `${normalizedPath}/${file.name}` resolve({ path: uploadedPath, filename: file.name, @@ -145,6 +162,7 @@ export async function uploadFileToFileBrowser( * @param password - FileBrowser password * @param files - Array of files to upload * @param onProgress - Optional callback for upload progress + * @param targetPath - Target directory path relative to FileBrowser root (e.g., "/next/src"). Defaults to "/" if not provided. * @returns Batch upload result with succeeded/failed files */ export async function uploadFilesToFileBrowser( @@ -152,8 +170,12 @@ export async function uploadFilesToFileBrowser( username: string, password: string, files: File[], - onProgress?: (completed: number, total: number, currentFile: string) => void + onProgress?: (completed: number, total: number, currentFile: string) => void, + targetPath?: string ): Promise { + // Default to root path if not provided + const uploadPath = targetPath || '/' + // Login once and reuse token for all uploads const token = await loginToFileBrowser(fileBrowserUrl, username, password) @@ -167,7 +189,7 @@ export async function uploadFilesToFileBrowser( onProgress?.(i, total, file.name) try { - const result = await uploadFileToFileBrowser(fileBrowserUrl, token, file) + const result = await uploadFileToFileBrowser(fileBrowserUrl, token, file, uploadPath) succeeded.push(result) } catch (error) { failed.push({ @@ -187,7 +209,7 @@ export async function uploadFilesToFileBrowser( failed, total, allImages, - rootPath: '/', // FileBrowser uploads to root directory + rootPath: uploadPath, // Return the target path used for upload } } diff --git a/sandbox/ttyd-auth.sh b/sandbox/ttyd-auth.sh index e5a1de7..87d8c52 100644 --- a/sandbox/ttyd-auth.sh +++ b/sandbox/ttyd-auth.sh @@ -1,6 +1,10 @@ #!/bin/bash # ttyd authentication wrapper script # Validates TTYD_ACCESS_TOKEN before granting shell access +# +# Arguments (passed via URL ?arg=...&arg=...): +# $1 - TTYD_ACCESS_TOKEN (required) +# $2 - TERMINAL_SESSION_ID (optional, for file upload directory tracking) # Get the expected token from environment variable EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" @@ -26,5 +30,21 @@ if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then sleep infinity fi -# Authentication successful - start bash shell +# Authentication successful +echo "✓ Authentication successful" + +# Optional: Handle terminal session ID for file upload directory tracking +if [ "$#" -ge 2 ] && [ -n "$2" ]; then + TERMINAL_SESSION_ID="$2" + export TERMINAL_SESSION_ID + + # Store shell PID in session file + # This allows backend to find the shell's working directory via /proc/$PID/cwd + SESSION_FILE="/tmp/.terminal-session-${TERMINAL_SESSION_ID}" + echo "$$" > "$SESSION_FILE" + + echo "✓ Terminal session: ${TERMINAL_SESSION_ID}" +fi + +# Start bash shell exec /bin/bash From b4f4311468921798bcc699b1de2a83768d4dbd8f Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 19 Nov 2025 12:50:31 +0000 Subject: [PATCH 5/5] chore --- docs/technical-notes/TTYD_AUTHENTICATION.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/technical-notes/TTYD_AUTHENTICATION.md b/docs/technical-notes/TTYD_AUTHENTICATION.md index ebc48da..b7c9cb2 100644 --- a/docs/technical-notes/TTYD_AUTHENTICATION.md +++ b/docs/technical-notes/TTYD_AUTHENTICATION.md @@ -590,6 +590,9 @@ kubectl exec -it -n -- env | grep TTYD_ACCESS_TOKEN - [ttyd Protocol Documentation](https://github.com/tsl0922/ttyd/blob/main/docs/protocol.md) - [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) - [FullstackAgent Architecture](./TECHNICAL_DOCUMENTATION.md) +- [ttyd server.c](https://github.com/tsl0922/ttyd/blob/eccebc6bb1dfbaf0c46f1fd9c53b89abc773784d/src/server.c) +- [ttyd protocol.c](https://github.com/tsl0922/ttyd/blob/eccebc6bb1dfbaf0c46f1fd9c53b89abc773784d/src/protocol.c) +- [ttyd xterm](https://github.com/tsl0922/ttyd/blob/eccebc6bb1dfbaf0c46f1fd9c53b89abc773784d/html/src/components/terminal/xterm/index.ts#L264) ---