feat: SFTP gateway — file operations over WebSocket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
60d7b6b024
commit
56be3fc102
176
backend/src/terminal/sftp.gateway.ts
Normal file
176
backend/src/terminal/sftp.gateway.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Server } from 'ws';
|
||||||
|
import { WsAuthGuard } from '../auth/ws-auth.guard';
|
||||||
|
import { SshConnectionService } from './ssh-connection.service';
|
||||||
|
|
||||||
|
const MAX_EDIT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
@WebSocketGateway({ path: '/ws/sftp' })
|
||||||
|
export class SftpGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer() server: Server;
|
||||||
|
private readonly logger = new Logger(SftpGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ssh: SshConnectionService,
|
||||||
|
private wsAuth: WsAuthGuard,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
handleConnection(client: any) {
|
||||||
|
const user = this.wsAuth.validateClient(client);
|
||||||
|
if (!user) {
|
||||||
|
client.close(4001, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.log(`SFTP WS connected: ${user.email}`);
|
||||||
|
|
||||||
|
client.on('message', async (raw: Buffer) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
await this.handleMessage(client, msg);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.send(client, { type: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect() {}
|
||||||
|
|
||||||
|
private async handleMessage(client: any, msg: any) {
|
||||||
|
const { sessionId } = msg;
|
||||||
|
if (!sessionId) {
|
||||||
|
return this.send(client, { type: 'error', message: 'sessionId required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sftp = await this.ssh.getSftpChannel(sessionId);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'list': {
|
||||||
|
sftp.readdir(msg.path, (err: any, list: any[]) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
const entries = list.map((f: any) => ({
|
||||||
|
name: f.filename,
|
||||||
|
path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`,
|
||||||
|
size: f.attrs.size,
|
||||||
|
isDirectory: (f.attrs.mode & 0o40000) !== 0,
|
||||||
|
permissions: (f.attrs.mode & 0o7777).toString(8),
|
||||||
|
modified: new Date(f.attrs.mtime * 1000).toISOString(),
|
||||||
|
}));
|
||||||
|
this.send(client, { type: 'list', path: msg.path, entries });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'read': {
|
||||||
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
if (stats.size > MAX_EDIT_SIZE) {
|
||||||
|
return this.send(client, {
|
||||||
|
type: 'error',
|
||||||
|
message: `File too large for editing (${(stats.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Download instead.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const stream = sftp.createReadStream(msg.path);
|
||||||
|
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
stream.on('end', () => {
|
||||||
|
const content = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
this.send(client, { type: 'fileContent', path: msg.path, content, encoding: 'utf-8' });
|
||||||
|
});
|
||||||
|
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'write': {
|
||||||
|
const stream = sftp.createWriteStream(msg.path);
|
||||||
|
stream.end(Buffer.from(msg.data, 'utf-8'), () => {
|
||||||
|
this.send(client, { type: 'saved', path: msg.path });
|
||||||
|
});
|
||||||
|
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mkdir': {
|
||||||
|
sftp.mkdir(msg.path, (err: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
this.send(client, { type: 'created', path: msg.path });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rename': {
|
||||||
|
sftp.rename(msg.oldPath, msg.newPath, (err: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
this.send(client, { type: 'renamed', oldPath: msg.oldPath, newPath: msg.newPath });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Try unlink (file), fallback to rmdir (directory)
|
||||||
|
sftp.unlink(msg.path, (err: any) => {
|
||||||
|
if (err) {
|
||||||
|
sftp.rmdir(msg.path, (err2: any) => {
|
||||||
|
if (err2) return this.send(client, { type: 'error', message: err2.message });
|
||||||
|
this.send(client, { type: 'deleted', path: msg.path });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.send(client, { type: 'deleted', path: msg.path });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'chmod': {
|
||||||
|
const mode = parseInt(msg.mode, 8);
|
||||||
|
sftp.chmod(msg.path, mode, (err: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
this.send(client, { type: 'chmodDone', path: msg.path, mode: msg.mode });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stat': {
|
||||||
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
this.send(client, {
|
||||||
|
type: 'stat',
|
||||||
|
path: msg.path,
|
||||||
|
size: stats.size,
|
||||||
|
isDirectory: (stats.mode & 0o40000) !== 0,
|
||||||
|
permissions: (stats.mode & 0o7777).toString(8),
|
||||||
|
modified: new Date(stats.mtime * 1000).toISOString(),
|
||||||
|
accessed: new Date(stats.atime * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'download': {
|
||||||
|
// Stream file data to client in chunks
|
||||||
|
const readStream = sftp.createReadStream(msg.path);
|
||||||
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
||||||
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
const transferId = `dl-${Date.now()}`;
|
||||||
|
let sent = 0;
|
||||||
|
this.send(client, { type: 'downloadStart', transferId, path: msg.path, total: stats.size });
|
||||||
|
readStream.on('data', (chunk: Buffer) => {
|
||||||
|
sent += chunk.length;
|
||||||
|
client.send(JSON.stringify({
|
||||||
|
type: 'downloadChunk',
|
||||||
|
transferId,
|
||||||
|
data: chunk.toString('base64'),
|
||||||
|
progress: { bytes: sent, total: stats.size },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
readStream.on('end', () => {
|
||||||
|
this.send(client, { type: 'downloadComplete', transferId });
|
||||||
|
});
|
||||||
|
readStream.on('error', (e: any) => {
|
||||||
|
this.send(client, { type: 'error', message: e.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(client: any, data: any) {
|
||||||
|
if (client.readyState === 1) {
|
||||||
|
client.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user