latest changes
This commit is contained in:
@@ -9,6 +9,7 @@ import { connectionRoutes } from './routes/connections';
|
|||||||
import { profileRoutes } from './routes/profiles';
|
import { profileRoutes } from './routes/profiles';
|
||||||
import { sshWebsocket } from './websocket/ssh';
|
import { sshWebsocket } from './websocket/ssh';
|
||||||
import { rdpWebsocket } from './websocket/rdp';
|
import { rdpWebsocket } from './websocket/rdp';
|
||||||
|
import { sftpWebsocket } from './websocket/sftp';
|
||||||
import { encrypt } from './utils/encryption';
|
import { encrypt } from './utils/encryption';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -48,6 +49,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
// WebSocket routes
|
// WebSocket routes
|
||||||
await fastify.register(sshWebsocket);
|
await fastify.register(sshWebsocket);
|
||||||
await fastify.register(rdpWebsocket);
|
await fastify.register(rdpWebsocket);
|
||||||
|
await fastify.register(sftpWebsocket);
|
||||||
|
|
||||||
return fastify;
|
return fastify;
|
||||||
}
|
}
|
||||||
|
|||||||
283
backend/src/websocket/sftp.ts
Normal file
283
backend/src/websocket/sftp.ts
Normal file
@@ -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<JwtPayload>(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<SFTPWrapper['createWriteStream']> | 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -223,12 +223,23 @@ export const ConnectionTree: React.FC = () => {
|
|||||||
// --- Context menu items ---
|
// --- Context menu items ---
|
||||||
const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => {
|
const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => {
|
||||||
if (node.itemData.type === 'connection') {
|
if (node.itemData.type === 'connection') {
|
||||||
|
const conn = node.itemData.connection!;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'connect',
|
key: 'connect',
|
||||||
label: 'Connect',
|
label: 'Connect',
|
||||||
onClick: () => openSession(node.itemData.connection!),
|
onClick: () => openSession(conn),
|
||||||
},
|
},
|
||||||
|
...(conn.protocol === 'ssh'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'browseFiles',
|
||||||
|
icon: <FolderOpenOutlined />,
|
||||||
|
label: 'Browse Files',
|
||||||
|
onClick: () => openSession(conn, 'sftp'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
icon: <EditOutlined />,
|
icon: <EditOutlined />,
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tabs, Typography } from 'antd';
|
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 { useStore } from '../../store';
|
||||||
import { SshTab } from './SshTab';
|
import { SshTab } from './SshTab';
|
||||||
import { RdpTab } from './RdpTab';
|
import { RdpTab } from './RdpTab';
|
||||||
|
import { SftpTab } from './SftpTab';
|
||||||
import { Session } from '../../types';
|
import { Session } from '../../types';
|
||||||
|
|
||||||
function tabLabel(session: Session) {
|
function tabLabel(session: Session) {
|
||||||
const icon =
|
const isSftp = session.mode === 'sftp';
|
||||||
session.connection.protocol === 'rdp' ? (
|
const icon = isSftp ? (
|
||||||
<WindowsOutlined />
|
<FolderOutlined />
|
||||||
) : (
|
) : session.connection.protocol === 'rdp' ? (
|
||||||
<CodeOutlined />
|
<WindowsOutlined />
|
||||||
);
|
) : (
|
||||||
|
<CodeOutlined />
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
{icon}
|
{icon}
|
||||||
{session.connection.name}
|
{session.connection.name}{isSftp ? ' (Files)' : ''}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,7 +71,9 @@ export const SessionTabs: React.FC = () => {
|
|||||||
style: { height: '100%', padding: 0 },
|
style: { height: '100%', padding: 0 },
|
||||||
children: (
|
children: (
|
||||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||||
{session.connection.protocol === 'ssh' ? (
|
{session.mode === 'sftp' ? (
|
||||||
|
<SftpTab session={session} />
|
||||||
|
) : session.connection.protocol === 'ssh' ? (
|
||||||
<SshTab session={session} />
|
<SshTab session={session} />
|
||||||
) : (
|
) : (
|
||||||
<RdpTab session={session} />
|
<RdpTab session={session} />
|
||||||
|
|||||||
457
frontend/src/components/Tabs/SftpTab.tsx
Normal file
457
frontend/src/components/Tabs/SftpTab.tsx
Normal file
@@ -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<Props> = ({ session }) => {
|
||||||
|
const token = useStore((s) => s.token) ?? '';
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
|
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<FileEntry | null>(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<FileEntry> = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record) => (
|
||||||
|
<span
|
||||||
|
style={{ cursor: record.isDir ? 'pointer' : 'default', display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
|
onClick={() => record.isDir && navigateTo(name === '..' ? parentPath(currentPath) : joinPath(currentPath, name))}
|
||||||
|
>
|
||||||
|
{record.isDir ? <FolderOutlined style={{ color: name === '..' ? undefined : '#faad14' }} /> : <FileOutlined />}
|
||||||
|
<span style={{ color: record.isDir ? '#1677ff' : undefined }}>{name}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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 : (
|
||||||
|
<Space size="small">
|
||||||
|
{!record.isDir && (
|
||||||
|
<Tooltip title="Download">
|
||||||
|
<Button type="text" size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Rename">
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleRename(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const segments = pathSegments(currentPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Breadcrumb
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<span style={{ cursor: 'pointer' }} onClick={() => navigateTo('/')}>
|
||||||
|
<HomeOutlined />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...segments.map((seg) => ({
|
||||||
|
title: (
|
||||||
|
<span style={{ cursor: 'pointer' }} onClick={() => navigateTo(seg.path)}>
|
||||||
|
{seg.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Space size="small">
|
||||||
|
<Upload beforeUpload={handleUpload as any} showUploadList={false} multiple={false}>
|
||||||
|
<Button size="small" icon={<UploadOutlined />}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FolderAddOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setNewFolderName('');
|
||||||
|
setMkdirModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Folder
|
||||||
|
</Button>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={() => listDir(currentPath)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File table */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '0 12px' }}>
|
||||||
|
{!connected ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
<Typography.Text type="secondary">Connecting to SFTP...</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table<FileEntry>
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Folder Modal */}
|
||||||
|
<Modal
|
||||||
|
title="New Folder"
|
||||||
|
open={mkdirModalOpen}
|
||||||
|
onOk={handleMkdir}
|
||||||
|
onCancel={() => setMkdirModalOpen(false)}
|
||||||
|
okText="Create"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Folder name"
|
||||||
|
value={newFolderName}
|
||||||
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
onPressEnter={handleMkdir}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Rename Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`Rename "${renameEntry?.name}"`}
|
||||||
|
open={renameModalOpen}
|
||||||
|
onOk={commitRename}
|
||||||
|
onCancel={() => setRenameModalOpen(false)}
|
||||||
|
okText="Rename"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="New name"
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onPressEnter={commitRename}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ interface AppState {
|
|||||||
// Open sessions (tabs)
|
// Open sessions (tabs)
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
openSession: (connection: Connection) => void;
|
openSession: (connection: Connection, mode?: 'shell' | 'sftp') => void;
|
||||||
closeSession: (sessionId: string) => void;
|
closeSession: (sessionId: string) => void;
|
||||||
setActiveSession: (sessionId: string) => void;
|
setActiveSession: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -76,14 +76,17 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
|
|
||||||
openSession: (connection) => {
|
openSession: (connection, mode) => {
|
||||||
const existing = get().sessions.find((s) => s.connection.id === connection.id);
|
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) {
|
if (existing) {
|
||||||
set({ activeSessionId: existing.id });
|
set({ activeSessionId: existing.id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = `session-${Date.now()}-${Math.random()}`;
|
const id = `session-${Date.now()}-${Math.random()}`;
|
||||||
const session: Session = { id, connection };
|
const session: Session = { id, connection, mode: sessionMode };
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sessions: [...state.sessions, session],
|
sessions: [...state.sessions, session],
|
||||||
activeSessionId: id,
|
activeSessionId: id,
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ export interface Session {
|
|||||||
/** Unique ID for this open tab */
|
/** Unique ID for this open tab */
|
||||||
id: string;
|
id: string;
|
||||||
connection: Connection;
|
connection: Connection;
|
||||||
|
mode?: 'shell' | 'sftp';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user