diff --git a/backend/src/index.ts b/backend/src/index.ts index e5ef9e3..49b6e7d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { connectionRoutes } from './routes/connections'; import { profileRoutes } from './routes/profiles'; import { sshWebsocket } from './websocket/ssh'; import { rdpWebsocket } from './websocket/rdp'; +import { sftpWebsocket } from './websocket/sftp'; import { encrypt } from './utils/encryption'; declare module 'fastify' { @@ -48,6 +49,7 @@ async function buildApp(): Promise { // WebSocket routes await fastify.register(sshWebsocket); await fastify.register(rdpWebsocket); + await fastify.register(sftpWebsocket); return fastify; } diff --git a/backend/src/websocket/sftp.ts b/backend/src/websocket/sftp.ts new file mode 100644 index 0000000..b7cb19d --- /dev/null +++ b/backend/src/websocket/sftp.ts @@ -0,0 +1,283 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { SocketStream } from '@fastify/websocket'; +import { WebSocket } from 'ws'; +import { Client as SshClient, ConnectConfig, SFTPWrapper } from 'ssh2'; +import { resolveCredentials } from '../utils/resolveCredentials'; + +interface JwtPayload { + id: string; + username: string; +} + +function sendJson(socket: WebSocket, msg: object) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(msg)); + } +} + +export async function sftpWebsocket(fastify: FastifyInstance) { + fastify.get( + '/ws/sftp/:connectionId', + { websocket: true }, + async (connection: SocketStream, request: FastifyRequest) => { + const socket = connection.socket; + + // --- Auth via ?token= query param --- + const query = request.query as { token?: string }; + let userId: string; + + try { + if (!query.token) throw new Error('No token'); + const payload = fastify.jwt.verify(query.token); + userId = payload.id; + } catch { + socket.close(1008, 'Unauthorized'); + return; + } + + // --- Fetch connection --- + const { connectionId } = request.params as { connectionId: string }; + const conn = await fastify.prisma.connection.findFirst({ + where: { id: connectionId, userId }, + include: { profile: true }, + }); + + if (!conn) { + socket.close(1008, 'Connection not found'); + return; + } + + // --- Build SSH config --- + const creds = resolveCredentials(conn); + const sshConfig: ConnectConfig = { + host: conn.host, + port: conn.port, + username: creds.username, + readyTimeout: 10_000, + }; + + if (creds.privateKey) { + sshConfig.privateKey = creds.privateKey; + } else if (creds.password) { + sshConfig.password = creds.password; + } + + // --- Open SSH + SFTP session --- + const ssh = new SshClient(); + let sftp: SFTPWrapper | null = null; + + // Upload state + let uploadPath: string | null = null; + let uploadStream: ReturnType | null = null; + + ssh.on('ready', () => { + ssh.sftp((err, sftpSession) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + socket.close(); + ssh.end(); + return; + } + + sftp = sftpSession; + + // Resolve home directory and send ready + sftp.realpath('.', (rpErr, home) => { + sendJson(socket, { + type: 'ready', + home: rpErr ? '/' : home, + }); + }); + }); + }); + + // --- Handle incoming messages --- + socket.on('message', (message: Buffer | string, isBinary: boolean) => { + if (!sftp) return; + + // Binary frames → upload data + if (isBinary) { + if (uploadStream) { + const buf = Buffer.isBuffer(message) ? message : Buffer.from(message as string); + uploadStream.write(buf); + } + return; + } + + // JSON commands + let msg: any; + try { + msg = JSON.parse(message.toString()); + } catch { + sendJson(socket, { type: 'error', message: 'Invalid JSON' }); + return; + } + + switch (msg.type) { + case 'list': { + const dirPath: string = msg.path || '/'; + sftp.readdir(dirPath, (err, list) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + return; + } + const entries = list + .filter((item) => item.filename !== '.' && item.filename !== '..') + .map((item) => ({ + name: item.filename, + isDir: (item.attrs.mode! & 0o40000) !== 0, + size: item.attrs.size ?? 0, + modTime: (item.attrs.mtime ?? 0) * 1000, + })) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + sendJson(socket, { type: 'entries', path: dirPath, entries }); + }); + break; + } + + case 'download': { + const filePath: string = msg.path; + if (!filePath) { + sendJson(socket, { type: 'error', message: 'Missing path' }); + return; + } + sftp.stat(filePath, (err, stats) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + return; + } + const name = filePath.split('/').pop() || 'download'; + sendJson(socket, { type: 'downloadStart', name, size: stats.size }); + + const rs = sftp!.createReadStream(filePath); + rs.on('data', (chunk: Buffer) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(chunk); + } + }); + rs.on('end', () => { + sendJson(socket, { type: 'downloadEnd' }); + }); + rs.on('error', (readErr: Error) => { + sendJson(socket, { type: 'error', message: readErr.message }); + }); + }); + break; + } + + case 'upload': { + const upPath: string = msg.path; + if (!upPath) { + sendJson(socket, { type: 'error', message: 'Missing path' }); + return; + } + uploadPath = upPath; + uploadStream = sftp.createWriteStream(upPath); + uploadStream.on('error', (writeErr: Error) => { + sendJson(socket, { type: 'error', message: writeErr.message }); + uploadStream = null; + uploadPath = null; + }); + break; + } + + case 'uploadEnd': { + if (uploadStream) { + uploadStream.end(() => { + sendJson(socket, { type: 'ok' }); + uploadStream = null; + uploadPath = null; + }); + } + break; + } + + case 'mkdir': { + const mkPath: string = msg.path; + if (!mkPath) { + sendJson(socket, { type: 'error', message: 'Missing path' }); + return; + } + sftp.mkdir(mkPath, (err) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + } else { + sendJson(socket, { type: 'ok' }); + } + }); + break; + } + + case 'delete': { + const delPath: string = msg.path; + if (!delPath) { + sendJson(socket, { type: 'error', message: 'Missing path' }); + return; + } + sftp.stat(delPath, (statErr, stats) => { + if (statErr) { + sendJson(socket, { type: 'error', message: statErr.message }); + return; + } + const isDir = (stats.mode! & 0o40000) !== 0; + const cb = (err: Error | null | undefined) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + } else { + sendJson(socket, { type: 'ok' }); + } + }; + if (isDir) { + sftp!.rmdir(delPath, cb); + } else { + sftp!.unlink(delPath, cb); + } + }); + break; + } + + case 'rename': { + const { oldPath, newPath } = msg; + if (!oldPath || !newPath) { + sendJson(socket, { type: 'error', message: 'Missing oldPath or newPath' }); + return; + } + sftp.rename(oldPath, newPath, (err) => { + if (err) { + sendJson(socket, { type: 'error', message: err.message }); + } else { + sendJson(socket, { type: 'ok' }); + } + }); + break; + } + + default: + sendJson(socket, { type: 'error', message: `Unknown command: ${msg.type}` }); + } + }); + + ssh.on('error', (err) => { + fastify.log.error({ err }, 'SFTP SSH connection error'); + sendJson(socket, { type: 'error', message: err.message }); + if (socket.readyState === WebSocket.OPEN) { + socket.close(1011, err.message); + } + }); + + ssh.connect(sshConfig); + + socket.on('close', () => { + if (uploadStream) { + uploadStream.destroy(); + uploadStream = null; + } + ssh.end(); + }); + } + ); +} diff --git a/frontend/src/components/Sidebar/ConnectionTree.tsx b/frontend/src/components/Sidebar/ConnectionTree.tsx index fc01c54..4bae657 100644 --- a/frontend/src/components/Sidebar/ConnectionTree.tsx +++ b/frontend/src/components/Sidebar/ConnectionTree.tsx @@ -223,12 +223,23 @@ export const ConnectionTree: React.FC = () => { // --- Context menu items --- const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => { if (node.itemData.type === 'connection') { + const conn = node.itemData.connection!; return [ { key: 'connect', label: 'Connect', - onClick: () => openSession(node.itemData.connection!), + onClick: () => openSession(conn), }, + ...(conn.protocol === 'ssh' + ? [ + { + key: 'browseFiles', + icon: , + label: 'Browse Files', + onClick: () => openSession(conn, 'sftp'), + }, + ] + : []), { key: 'edit', icon: , diff --git a/frontend/src/components/Tabs/SessionTabs.tsx b/frontend/src/components/Tabs/SessionTabs.tsx index 5874606..e1d08b1 100644 --- a/frontend/src/components/Tabs/SessionTabs.tsx +++ b/frontend/src/components/Tabs/SessionTabs.tsx @@ -1,22 +1,25 @@ import React from 'react'; import { Tabs, Typography } from 'antd'; -import { CodeOutlined, WindowsOutlined } from '@ant-design/icons'; +import { CodeOutlined, WindowsOutlined, FolderOutlined } from '@ant-design/icons'; import { useStore } from '../../store'; import { SshTab } from './SshTab'; import { RdpTab } from './RdpTab'; +import { SftpTab } from './SftpTab'; import { Session } from '../../types'; function tabLabel(session: Session) { - const icon = - session.connection.protocol === 'rdp' ? ( - - ) : ( - - ); + const isSftp = session.mode === 'sftp'; + const icon = isSftp ? ( + + ) : session.connection.protocol === 'rdp' ? ( + + ) : ( + + ); return ( {icon} - {session.connection.name} + {session.connection.name}{isSftp ? ' (Files)' : ''} ); } @@ -68,7 +71,9 @@ export const SessionTabs: React.FC = () => { style: { height: '100%', padding: 0 }, children: (
- {session.connection.protocol === 'ssh' ? ( + {session.mode === 'sftp' ? ( + + ) : session.connection.protocol === 'ssh' ? ( ) : ( diff --git a/frontend/src/components/Tabs/SftpTab.tsx b/frontend/src/components/Tabs/SftpTab.tsx new file mode 100644 index 0000000..7e111aa --- /dev/null +++ b/frontend/src/components/Tabs/SftpTab.tsx @@ -0,0 +1,457 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Table, Breadcrumb, Button, Space, Upload, Input, Modal, message, Tooltip, Typography } from 'antd'; +import { + FolderOutlined, + FileOutlined, + DownloadOutlined, + DeleteOutlined, + EditOutlined, + FolderAddOutlined, + UploadOutlined, + ReloadOutlined, + HomeOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { useStore } from '../../store'; +import { Session } from '../../types'; + +interface Props { + session: Session; +} + +interface FileEntry { + name: string; + isDir: boolean; + size: number; + modTime: number; +} + +function humanSize(bytes: number): string { + if (bytes === 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const val = bytes / Math.pow(1024, i); + return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`; +} + +function formatDate(ms: number): string { + if (!ms) return '—'; + return new Date(ms).toLocaleString(); +} + +function getWsUrl(connectionId: string, token: string): string { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + return `${proto}//${host}/ws/sftp/${connectionId}?token=${encodeURIComponent(token)}`; +} + +function joinPath(base: string, name: string): string { + if (base === '/') return '/' + name; + return base + '/' + name; +} + +function parentPath(path: string): string { + if (path === '/') return '/'; + const parts = path.split('/').filter(Boolean); + parts.pop(); + return '/' + parts.join('/'); +} + +function pathSegments(path: string): { name: string; path: string }[] { + const parts = path.split('/').filter(Boolean); + const segments: { name: string; path: string }[] = []; + for (let i = 0; i < parts.length; i++) { + segments.push({ + name: parts[i], + path: '/' + parts.slice(0, i + 1).join('/'), + }); + } + return segments; +} + +export const SftpTab: React.FC = ({ session }) => { + const token = useStore((s) => s.token) ?? ''; + const wsRef = useRef(null); + const [connected, setConnected] = useState(false); + const [currentPath, setCurrentPath] = useState('/'); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [homePath, setHomePath] = useState('/'); + + // Download state + const downloadRef = useRef<{ name: string; size: number; chunks: ArrayBuffer[] } | null>(null); + + // Upload state + const uploadingRef = useRef(false); + + // New folder state + const [mkdirModalOpen, setMkdirModalOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + + // Rename state + const [renameModalOpen, setRenameModalOpen] = useState(false); + const [renameEntry, setRenameEntry] = useState(null); + const [renameName, setRenameName] = useState(''); + + const sendJson = useCallback((msg: object) => { + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + }, []); + + const listDir = useCallback( + (path: string) => { + setLoading(true); + sendJson({ type: 'list', path }); + }, + [sendJson] + ); + + const navigateTo = useCallback( + (path: string) => { + setCurrentPath(path); + listDir(path); + }, + [listDir] + ); + + // WebSocket lifecycle + useEffect(() => { + const url = getWsUrl(session.connection.id, token); + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onmessage = (event) => { + // Binary frame → download chunk + if (event.data instanceof ArrayBuffer) { + if (downloadRef.current) { + downloadRef.current.chunks.push(event.data as ArrayBuffer); + } + return; + } + + let msg: any; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + + switch (msg.type) { + case 'ready': + setConnected(true); + setHomePath(msg.home || '/'); + setCurrentPath(msg.home || '/'); + setLoading(true); + ws.send(JSON.stringify({ type: 'list', path: msg.home || '/' })); + break; + + case 'entries': { + const serverEntries: FileEntry[] = msg.entries || []; + if (msg.path !== '/') { + serverEntries.unshift({ name: '..', isDir: true, size: 0, modTime: 0 }); + } + setEntries(serverEntries); + setCurrentPath(msg.path); + setLoading(false); + break; + } + + case 'downloadStart': + downloadRef.current = { name: msg.name, size: msg.size, chunks: [] }; + break; + + case 'downloadEnd': { + const dl = downloadRef.current; + if (dl) { + const blob = new Blob(dl.chunks); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = dl.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + downloadRef.current = null; + } + break; + } + + case 'ok': + if (uploadingRef.current) { + uploadingRef.current = false; + message.success('Upload complete'); + } + // Refresh listing after any mutation + listDir(currentPath); + break; + + case 'error': + setLoading(false); + uploadingRef.current = false; + message.error(msg.message || 'SFTP error'); + break; + } + }; + + ws.onerror = () => { + message.error('SFTP WebSocket error'); + }; + + ws.onclose = () => { + setConnected(false); + }; + + return () => { + ws.close(); + wsRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.connection.id, token]); + + // --- Handlers --- + const handleDownload = (entry: FileEntry) => { + sendJson({ type: 'download', path: joinPath(currentPath, entry.name) }); + }; + + const handleDelete = (entry: FileEntry) => { + Modal.confirm({ + title: `Delete "${entry.name}"?`, + content: entry.isDir ? 'This will delete the directory (must be empty).' : 'This file will be permanently deleted.', + okText: 'Delete', + okButtonProps: { danger: true }, + onOk: () => { + sendJson({ type: 'delete', path: joinPath(currentPath, entry.name) }); + }, + }); + }; + + const handleRename = (entry: FileEntry) => { + setRenameEntry(entry); + setRenameName(entry.name); + setRenameModalOpen(true); + }; + + const commitRename = () => { + if (renameEntry && renameName.trim() && renameName !== renameEntry.name) { + sendJson({ + type: 'rename', + oldPath: joinPath(currentPath, renameEntry.name), + newPath: joinPath(currentPath, renameName.trim()), + }); + } + setRenameModalOpen(false); + setRenameEntry(null); + setRenameName(''); + }; + + const handleMkdir = () => { + if (newFolderName.trim()) { + sendJson({ type: 'mkdir', path: joinPath(currentPath, newFolderName.trim()) }); + } + setMkdirModalOpen(false); + setNewFolderName(''); + }; + + const handleUpload = (file: File) => { + uploadingRef.current = true; + const uploadPath = joinPath(currentPath, file.name); + sendJson({ type: 'upload', path: uploadPath, size: file.size }); + + const reader = new FileReader(); + reader.onload = () => { + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN && reader.result instanceof ArrayBuffer) { + // Send in 64KB chunks + const data = new Uint8Array(reader.result); + const CHUNK = 64 * 1024; + for (let offset = 0; offset < data.length; offset += CHUNK) { + ws.send(data.slice(offset, offset + CHUNK)); + } + sendJson({ type: 'uploadEnd' }); + } + }; + reader.readAsArrayBuffer(file); + return false; // Prevent Ant Upload from doing its own upload + }; + + // --- Table columns --- + const columns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string, record) => ( + record.isDir && navigateTo(name === '..' ? parentPath(currentPath) : joinPath(currentPath, name))} + > + {record.isDir ? : } + {name} + + ), + sorter: (a, b) => { + if (a.name === '..') return -1; + if (b.name === '..') return 1; + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }, + defaultSortOrder: 'ascend', + }, + { + title: 'Size', + dataIndex: 'size', + key: 'size', + width: 100, + render: (size: number, record) => (record.isDir ? '—' : humanSize(size)), + sorter: (a, b) => a.size - b.size, + }, + { + title: 'Modified', + dataIndex: 'modTime', + key: 'modTime', + width: 180, + render: (ms: number) => formatDate(ms), + sorter: (a, b) => a.modTime - b.modTime, + }, + { + title: 'Actions', + key: 'actions', + width: 120, + render: (_, record) => record.name === '..' ? null : ( + + {!record.isDir && ( + + + + + +
+ + {/* File table */} +
+ {!connected ? ( +
+ Connecting to SFTP... +
+ ) : ( + + dataSource={entries} + columns={columns} + rowKey="name" + size="small" + loading={loading} + pagination={false} + locale={{ emptyText: 'Empty directory' }} + onRow={(record) => ({ + onDoubleClick: () => { + if (record.isDir) navigateTo(record.name === '..' ? parentPath(currentPath) : joinPath(currentPath, record.name)); + }, + })} + /> + )} +
+ + {/* New Folder Modal */} + setMkdirModalOpen(false)} + okText="Create" + > + setNewFolderName(e.target.value)} + onPressEnter={handleMkdir} + autoFocus + /> + + + {/* Rename Modal */} + setRenameModalOpen(false)} + okText="Rename" + > + setRenameName(e.target.value)} + onPressEnter={commitRename} + autoFocus + /> + + + ); +}; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 15998d9..2389d59 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -27,7 +27,7 @@ interface AppState { // Open sessions (tabs) sessions: Session[]; activeSessionId: string | null; - openSession: (connection: Connection) => void; + openSession: (connection: Connection, mode?: 'shell' | 'sftp') => void; closeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; } @@ -76,14 +76,17 @@ export const useStore = create((set, get) => ({ sessions: [], activeSessionId: null, - openSession: (connection) => { - const existing = get().sessions.find((s) => s.connection.id === connection.id); + openSession: (connection, mode) => { + const sessionMode = mode || (connection.protocol === 'rdp' ? undefined : 'shell'); + const existing = get().sessions.find( + (s) => s.connection.id === connection.id && (s.mode || 'shell') === (sessionMode || 'shell') + ); if (existing) { set({ activeSessionId: existing.id }); return; } const id = `session-${Date.now()}-${Math.random()}`; - const session: Session = { id, connection }; + const session: Session = { id, connection, mode: sessionMode }; set((state) => ({ sessions: [...state.sessions, session], activeSessionId: id, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d66cd03..bec2a0d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -70,4 +70,5 @@ export interface Session { /** Unique ID for this open tab */ id: string; connection: Connection; + mode?: 'shell' | 'sftp'; }