diff --git a/backend/src/terminal/sftp.gateway.ts b/backend/src/terminal/sftp.gateway.ts new file mode 100644 index 0000000..94b892c --- /dev/null +++ b/backend/src/terminal/sftp.gateway.ts @@ -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)); + } + } +}