Files
nexus-terminal/packages/backend/src/services/sftp.service.ts
T
Baobhan Sith c026a42d06 update
2025-04-15 18:59:56 +08:00

619 lines
36 KiB
TypeScript

import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream
import { WebSocket } from 'ws';
import { ClientState } from '../websocket'; // 导入统一的 ClientState
// 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配)
// Note: This interface seems out of place here, but keeping it for now as it was in the original file.
// Ideally, it should be in a shared types file.
interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
memUsed?: number; // MB
memTotal?: number; // MB
swapPercent?: number;
swapUsed?: number; // MB
swapTotal?: number; // MB
diskPercent?: number;
diskUsed?: number; // KB
diskTotal?: number; // KB
cpuModel?: string;
netRxRate?: number; // Bytes per second
netTxRate?: number; // Bytes per second
netInterface?: string;
osName?: string;
loadAvg?: number[]; // 系统平均负载 [1min, 5min, 15min]
timestamp: number; // 状态获取时间戳
}
// Interface for parsed network stats - Also seems out of place here.
interface NetworkStats {
[interfaceName: string]: {
rx_bytes: number;
tx_bytes: number;
}
}
// Note: These constants seem related to StatusMonitorService, not SftpService.
const DEFAULT_POLLING_INTERVAL = 1000;
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
// Interface for tracking active uploads
interface ActiveUpload {
remotePath: string;
totalSize: number;
bytesWritten: number;
stream: WriteStream;
sessionId: string; // Link back to the session for cleanup
}
export class SftpService {
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
private activeUploads: Map<string, ActiveUpload>; // Map<uploadId, ActiveUpload>
constructor(clientStates: Map<string, ClientState>) {
this.clientStates = clientStates;
this.activeUploads = new Map(); // Initialize the map
}
/**
* 初始化 SFTP 会话
* @param sessionId 会话 ID
*/
async initializeSftpSession(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient || state.sftp) {
console.warn(`[SFTP] 无法为会话 ${sessionId} 初始化 SFTP:状态无效、SSH客户端不存在或 SFTP 已初始化。`);
return;
}
if (!state.sshClient) {
console.error(`[SFTP] 会话 ${sessionId} 的 SSH 客户端不存在,无法初始化 SFTP。`);
return;
}
return new Promise((resolve, reject) => {
state.sshClient.sftp((err, sftpInstance) => {
if (err) {
console.error(`[SFTP] 为会话 ${sessionId} 初始化 SFTP 会话失败:`, err);
state.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId: state.dbConnectionId, message: 'SFTP 初始化失败' } }));
reject(err);
} else {
console.log(`[SFTP] 为会话 ${sessionId} 初始化 SFTP 会话成功。`);
state.sftp = sftpInstance;
state.ws.send(JSON.stringify({ type: 'sftp_ready', payload: { connectionId: state.dbConnectionId } }));
sftpInstance.on('end', () => {
console.log(`[SFTP] 会话 ${sessionId} 的 SFTP 会话已结束。`);
if (state) state.sftp = undefined;
});
sftpInstance.on('close', () => {
console.log(`[SFTP] 会话 ${sessionId} 的 SFTP 会话已关闭。`);
if (state) state.sftp = undefined;
});
sftpInstance.on('error', (sftpErr: Error) => {
console.error(`[SFTP] 会话 ${sessionId} 的 SFTP 会话出错:`, sftpErr);
if (state) state.sftp = undefined;
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId: state.dbConnectionId, message: 'SFTP 会话错误' } }));
});
resolve();
}
});
});
}
/**
* 清理 SFTP 会话
* @param sessionId 会话 ID
*/
cleanupSftpSession(sessionId: string): void {
const state = this.clientStates.get(sessionId);
if (state?.sftp) {
console.log(`[SFTP] 正在清理 ${sessionId} 的 SFTP 会话...`);
state.sftp.end();
state.sftp = undefined;
}
// Also clean up any active uploads associated with this session
this.activeUploads.forEach((upload, uploadId) => {
if (upload.sessionId === sessionId) {
console.warn(`[SFTP] Cleaning up active upload ${uploadId} for session ${sessionId} due to SFTP session cleanup.`);
this.cancelUploadInternal(uploadId, 'SFTP session ended'); // Internal cancel without sending message
}
});
}
// --- SFTP 操作方法 ---
/** 读取目录内容 */
async readdir(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SFTP ${sessionId}] Received readdir request for ${path} (ID: ${requestId})`);
try {
state.sftp.readdir(path, (err, list) => {
if (err) {
console.error(`[SFTP ${sessionId}] readdir ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: `读取目录失败: ${err.message}`, requestId: requestId }));
} else {
const files = list.map((item) => ({
filename: item.filename,
longname: item.longname,
attrs: {
size: item.attrs.size, uid: item.attrs.uid, gid: item.attrs.gid, mode: item.attrs.mode,
atime: item.attrs.atime * 1000, mtime: item.attrs.mtime * 1000,
isDirectory: item.attrs.isDirectory(), isFile: item.attrs.isFile(), isSymbolicLink: item.attrs.isSymbolicLink(),
}
}));
state.ws.send(JSON.stringify({ type: 'sftp:readdir:success', path: path, payload: files, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] readdir ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: `读取目录时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 获取文件/目录状态信息 */
async stat(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 stat (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received stat request for ${path} (ID: ${requestId})`);
try {
state.sftp.lstat(path, (err, stats: Stats) => {
if (err) {
console.error(`[SFTP ${sessionId}] stat ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: `获取状态失败: ${err.message}`, requestId: requestId }));
} else {
const fileStats = {
size: stats.size, uid: stats.uid, gid: stats.gid, mode: stats.mode,
atime: stats.atime * 1000, mtime: stats.mtime * 1000,
isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink(),
};
// Send specific success type
state.ws.send(JSON.stringify({ type: 'sftp:stat:success', path: path, payload: fileStats, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] stat ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: `获取状态时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 读取文件内容 */
async readFile(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId})`);
try {
const readStream = state.sftp.createReadStream(path);
let fileData = Buffer.alloc(0);
let errorOccurred = false;
readStream.on('data', (chunk: Buffer) => { fileData = Buffer.concat([fileData, chunk]); });
readStream.on('error', (err: Error) => {
if (errorOccurred) return; errorOccurred = true;
console.error(`[SFTP ${sessionId}] readFile ${path} stream error (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件流错误: ${err.message}`, requestId: requestId }));
});
readStream.on('end', () => {
if (!errorOccurred) {
console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:readfile:success', path: path, payload: { content: fileData.toString('base64'), encoding: 'base64' }, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] readFile ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 写入文件内容 */
async writefile(sessionId: string, path: string, data: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 writefile (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId})`);
try {
const buffer = Buffer.from(data, 'utf8');
console.debug(`[SFTP ${sessionId}] Creating write stream for ${path} (ID: ${requestId})`);
const writeStream = state.sftp.createWriteStream(path);
let errorOccurred = false;
writeStream.on('error', (err: Error) => {
if (errorOccurred) return; // Prevent sending multiple errors
errorOccurred = true;
console.error(`[SFTP ${sessionId}] writefile ${path} stream error (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `写入文件流错误: ${err.message}`, requestId: requestId }));
});
// Listen for the 'close' event which indicates the stream has finished writing and the file descriptor is closed.
writeStream.on('close', () => {
if (!errorOccurred) {
console.log(`[SFTP ${sessionId}] writefile ${path} stream closed successfully (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:writefile:success', path: path, requestId: requestId }));
}
});
console.debug(`[SFTP ${sessionId}] Writing ${buffer.length} bytes to ${path} (ID: ${requestId})`);
writeStream.end(buffer); // Start writing and close the stream afterwards
console.debug(`[SFTP ${sessionId}] writefile ${path} end() called (ID: ${requestId})`);
// Success message is now sent in the 'close' event handler
} catch (error: any) {
console.error(`[SFTP ${sessionId}] writefile ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `写入文件时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 创建目录 */
async mkdir(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 mkdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received mkdir request for ${path} (ID: ${requestId})`);
try {
state.sftp.mkdir(path, (err) => {
if (err) {
console.error(`[SFTP ${sessionId}] mkdir ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: `创建目录失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] mkdir ${path} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:success', path: path, requestId: requestId })); // Send specific success type
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] mkdir ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: `创建目录时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 删除目录 (强制递归) */
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
// 检查 SSH 客户端是否存在,而不是 SFTP 实例
if (!state || !state.sshClient) {
console.warn(`[SSH Exec] SSH 客户端未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SSH 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SSH Exec ${sessionId}] Received rmdir (force) request for ${path} (ID: ${requestId})`);
// 构建 rm -rf 命令,确保路径被正确引用
const command = `rm -rf "${path.replace(/"/g, '\\"')}"`; // Basic quoting for paths with spaces/quotes
console.log(`[SSH Exec ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
try {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[SSH Exec ${sessionId}] Failed to start exec for rmdir ${path} (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除命令失败: ${err.message}`, requestId: requestId }));
return;
}
let stderrOutput = '';
stream.stderr.on('data', (data: Buffer) => {
stderrOutput += data.toString();
});
stream.on('close', (code: number | null, signal: string | null) => {
if (code === 0) {
console.log(`[SSH Exec ${sessionId}] rmdir ${path} command executed successfully (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId }));
} else {
const errorMessage = stderrOutput.trim() || `命令退出,代码: ${code ?? 'N/A'}${signal ? `, 信号: ${signal}` : ''}`;
console.error(`[SSH Exec ${sessionId}] rmdir ${path} command failed (ID: ${requestId}). Code: ${code}, Signal: ${signal}, Stderr: ${stderrOutput}`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${errorMessage}`, requestId: requestId }));
}
});
stream.on('data', (data: Buffer) => {
// 通常 rm -rf 成功时 stdout 没有输出,但可以记录以防万一
console.debug(`[SSH Exec ${sessionId}] rmdir stdout (ID: ${requestId}): ${data.toString()}`);
});
});
} catch (error: any) {
console.error(`[SSH Exec ${sessionId}] rmdir ${path} caught unexpected error during exec setup (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 删除文件 */
async unlink(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 unlink (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received unlink request for ${path} (ID: ${requestId})`);
try {
state.sftp.unlink(path, (err) => {
if (err) {
console.error(`[SFTP ${sessionId}] unlink ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: `删除文件失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] unlink ${path} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:unlink:success', path: path, requestId: requestId })); // Send specific success type
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] unlink ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: `删除文件时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 重命名/移动文件或目录 */
async rename(sessionId: string, oldPath: string, newPath: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 rename (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received rename request ${oldPath} -> ${newPath} (ID: ${requestId})`);
try {
state.sftp.rename(oldPath, newPath, (err) => {
if (err) {
console.error(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: `重命名/移动失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:rename:success', oldPath: oldPath, newPath: newPath, requestId: requestId })); // Send specific success type
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: `重命名/移动时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
/** 修改文件/目录权限 */
async chmod(sessionId: string, path: string, mode: number, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 chmod (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received chmod request for ${path} to ${mode.toString(8)} (ID: ${requestId})`);
try {
state.sftp.chmod(path, mode, (err) => {
if (err) {
console.error(`[SFTP ${sessionId}] chmod ${path} to ${mode.toString(8)} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: `修改权限失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] chmod ${path} to ${mode.toString(8)} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:chmod:success', path: path, requestId: requestId })); // Send specific success type
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] chmod ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: `修改权限时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
// TODO: Implement file upload/download logic with progress reporting
// async uploadFile(...)
// async downloadFile(...)
/** 获取路径的绝对表示 */
async realpath(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 realpath (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SFTP ${sessionId}] Received realpath request for ${path} (ID: ${requestId})`);
try {
state.sftp.realpath(path, (err, absPath) => {
if (err) {
console.error(`[SFTP ${sessionId}] realpath ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] realpath ${path} -> ${absPath} success (ID: ${requestId})`);
// 在 payload 中同时发送请求的路径和绝对路径
state.ws.send(JSON.stringify({ type: 'sftp:realpath:success', path: path, payload: { requestedPath: path, absolutePath: absPath }, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] realpath ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
// --- File Upload Methods ---
/** Start a new file upload */
startUpload(sessionId: string, uploadId: string, remotePath: string, totalSize: number): void {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP Upload ${uploadId}] SFTP not ready for session ${sessionId}.`);
state?.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'SFTP 会话未就绪' } }));
return;
}
if (this.activeUploads.has(uploadId)) {
console.warn(`[SFTP Upload ${uploadId}] Upload already in progress for session ${sessionId}.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'Upload already started' } }));
return;
}
console.log(`[SFTP Upload ${uploadId}] Starting upload for ${remotePath} (${totalSize} bytes) in session ${sessionId}`);
try {
const stream = state.sftp.createWriteStream(remotePath);
const uploadState: ActiveUpload = {
remotePath,
totalSize,
bytesWritten: 0,
stream,
sessionId,
};
this.activeUploads.set(uploadId, uploadState);
stream.on('error', (err: Error) => {
console.error(`[SFTP Upload ${uploadId}] Write stream error for ${remotePath}:`, err);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入流错误: ${err.message}` } }));
this.activeUploads.delete(uploadId); // Clean up state on error
});
stream.on('close', () => {
// This 'close' event now primarily handles cleanup after the stream is fully closed.
// The success message is sent earlier in handleUploadChunk.
const finalState = this.activeUploads.get(uploadId);
if (finalState) {
// Check if bytes written match total size upon close, log warning if not (could indicate cancellation after success msg sent)
if (finalState.bytesWritten !== finalState.totalSize) {
console.warn(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but written bytes (${finalState.bytesWritten}) != total size (${finalState.totalSize}). This might happen if cancelled after success message was sent.`);
// Optionally send an error if this state is unexpected, but success might have already been sent.
// state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '文件大小不匹配或上传未完成' } }));
} else {
console.log(`[SFTP Upload ${uploadId}] Write stream closed successfully for ${remotePath}. State cleaned up.`);
}
this.activeUploads.delete(uploadId); // Clean up state when stream is closed
} else {
console.log(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but upload state was already removed.`);
}
});
stream.on('finish', () => {
// The 'finish' event fires when stream.end() is called and all data has been flushed to the underlying system.
// This might be a slightly earlier point than 'close'. Let's log it.
console.log(`[SFTP Upload ${uploadId}] Write stream finished for ${remotePath}. Waiting for close.`);
});
// Notify client that we are ready for chunks
state.ws.send(JSON.stringify({ type: 'sftp:upload:ready', payload: { uploadId } }));
} catch (error: any) {
console.error(`[SFTP Upload ${uploadId}] Error starting upload for ${remotePath}:`, error);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `开始上传时出错: ${error.message}` } }));
this.activeUploads.delete(uploadId); // Clean up if start failed
}
}
/** Handle an incoming file chunk */
handleUploadChunk(sessionId: string, uploadId: string, chunkIndex: number, dataBase64: string): void {
const state = this.clientStates.get(sessionId);
const uploadState = this.activeUploads.get(uploadId);
if (!state || !state.sftp) {
// Session or SFTP gone, can't process chunk. Upload might be cleaned up elsewhere.
console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but session ${sessionId} or SFTP is invalid.`);
this.cancelUploadInternal(uploadId, 'Session or SFTP invalid');
return;
}
if (!uploadState) {
console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but no active upload found.`);
// Send error back to client? Might flood if many chunks arrive after cancellation.
// state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } }));
return;
}
try {
const chunkBuffer = Buffer.from(dataBase64, 'base64');
// console.debug(`[SFTP Upload ${uploadId}] Writing chunk ${chunkIndex} (${chunkBuffer.length} bytes) to ${uploadState.remotePath}`);
// Write the chunk. The 'drain' event is handled automatically by Node.js streams
// if the write buffer is full. We just write.
const writeSuccess = uploadState.stream.write(chunkBuffer, (err) => {
if (err) {
// This callback handles errors specifically related to *this* write operation.
console.error(`[SFTP Upload ${uploadId}] Error writing chunk ${chunkIndex} to ${uploadState.remotePath}:`, err);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入块 ${chunkIndex} 失败: ${err.message}` } }));
// Consider cancelling the upload on write error
this.cancelUploadInternal(uploadId, `Write error on chunk ${chunkIndex}`);
}
// else { console.debug(`[SFTP Upload ${uploadId}] Chunk ${chunkIndex} write callback success.`); }
});
if (!writeSuccess) {
// This indicates the buffer is full and we should wait for 'drain'.
// However, for simplicity in this WebSocket context, we might rely on TCP backpressure
// or simply continue writing, letting the stream buffer handle it.
// Adding explicit 'drain' handling can add complexity.
console.warn(`[SFTP Upload ${uploadId}] Write stream buffer full after chunk ${chunkIndex}. Waiting for drain is recommended for large files/slow connections.`);
}
uploadState.bytesWritten += chunkBuffer.length;
// Send progress (optional, consider throttling)
// const progress = Math.round((uploadState.bytesWritten / uploadState.totalSize) * 100);
// state.ws.send(JSON.stringify({ type: 'sftp:upload:progress', payload: { uploadId, progress } }));
// Check if upload is complete
if (uploadState.bytesWritten > uploadState.totalSize) {
console.error(`[SFTP Upload ${uploadId}] Bytes written (${uploadState.bytesWritten}) exceeded total size (${uploadState.totalSize}) for ${uploadState.remotePath}.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '写入字节数超过文件总大小' } }));
this.cancelUploadInternal(uploadId, 'Bytes written exceeded total size');
} else if (uploadState.bytesWritten === uploadState.totalSize) {
console.log(`[SFTP Upload ${uploadId}] All bytes (${uploadState.bytesWritten}) received for ${uploadState.remotePath}. Sending success and ending stream.`);
// Send success message IMMEDIATELY upon receiving the last expected byte
state.ws.send(JSON.stringify({ type: 'sftp:upload:success', payload: { uploadId, remotePath: uploadState.remotePath } }));
// Now end the stream. The 'close' event will handle cleanup.
uploadState.stream.end();
}
} catch (error: any) {
console.error(`[SFTP Upload ${uploadId}] Error handling chunk ${chunkIndex} for ${uploadState?.remotePath}:`, error);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `处理块 ${chunkIndex} 时出错: ${error.message}` } }));
this.cancelUploadInternal(uploadId, `Error handling chunk ${chunkIndex}`);
}
}
/** Cancel an ongoing upload */
cancelUpload(sessionId: string, uploadId: string): void {
const state = this.clientStates.get(sessionId);
const uploadState = this.activeUploads.get(uploadId);
if (!state) {
console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but session ${sessionId} not found.`);
// Can't send message back if session is gone
this.cancelUploadInternal(uploadId, 'Session not found'); // Clean up if state exists
return;
}
if (!uploadState) {
console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but no active upload found.`);
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } }));
return;
}
console.log(`[SFTP Upload ${uploadId}] Cancelling upload for ${uploadState.remotePath}`);
this.cancelUploadInternal(uploadId, 'User cancelled');
state.ws.send(JSON.stringify({ type: 'sftp:upload:cancelled', payload: { uploadId } }));
}
/** Internal helper to clean up an upload */
private cancelUploadInternal(uploadId: string, reason: string): void {
const uploadState = this.activeUploads.get(uploadId);
if (uploadState) {
console.log(`[SFTP Upload ${uploadId}] Internal cancel (${reason}): Closing stream for ${uploadState.remotePath}`);
// End the stream. The 'close' handler should ideally detect the size mismatch or see the state is gone.
// Using destroy might be more immediate but could lead to unclosed file descriptors on the server in some cases.
uploadState.stream.end(); // Gracefully try to end
// uploadState.stream.destroy(); // More forceful, might be needed
this.activeUploads.delete(uploadId);
} else {
// console.log(`[SFTP Upload ${uploadId}] Internal cancel called, but upload state already removed.`);
}
}
}