feat: SFTP gateway — file operations over WebSocket

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:17:18 -04:00
parent 60d7b6b024
commit 56be3fc102

View 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));
}
}
}