feat: 为文件管理器添加右键“解/压缩”功能

Refs #28
This commit is contained in:
Baobhan Sith
2025-05-13 00:26:55 +08:00
parent 688b9df707
commit 52b797837e
18 changed files with 1126 additions and 82 deletions
+381 -7
View File
@@ -1,9 +1,18 @@
import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream (Removed Dirent)
import { WebSocket } from 'ws';
import { ClientState } from '../websocket'; // 导入统一的 ClientState
import { ClientState, AuthenticatedWebSocket } from '../websocket/types'; // 导入统一的 ClientState 和 AuthenticatedWebSocket
import * as pathModule from 'path'; // +++ Import path module +++
import * as jschardet from 'jschardet'; // +++ Import jschardet +++
import * as iconv from 'iconv-lite'; // +++ Import iconv-lite +++
// +++ 导入新类型 +++
import {
SftpCompressRequestPayload,
SftpCompressSuccessPayload,
SftpCompressErrorPayload,
SftpDecompressRequestPayload,
SftpDecompressSuccessPayload,
SftpDecompressErrorPayload
} from '../websocket/types';
// +++ Define local interface for readdir results +++
interface SftpDirEntry {
@@ -796,8 +805,7 @@ export class SftpService {
console.error(`[SFTP ${sessionId}] Move failed: Target path ${newPath} already exists (ID: ${requestId})`);
throw new Error(`目标路径 ${pathModule.basename(newPath)} 已存在`);
}
// --- 结束新增 ---
console.log(`[SFTP ${sessionId}] Moving ${oldPath} to ${newPath} (ID: ${requestId})`);
await this.performRename(sftp, oldPath, newPath); // Use helper for rename logic
@@ -1017,6 +1025,374 @@ export class SftpService {
}
// --- Compress/Decompress Methods ---
/**
* 压缩远程服务器上的文件/目录
* @param sessionId 会话 ID
* @param payload 压缩请求的 payload
*/
async compress(sessionId: string, payload: SftpCompressRequestPayload): Promise<void> {
const state = this.clientStates.get(sessionId);
const { sources, destinationArchiveName, format, targetDirectory, requestId } = payload;
if (!state || !state.sshClient) {
console.warn(`[SFTP Compress] SSH 客户端未准备好,无法在 ${sessionId} 上执行 compress (ID: ${requestId})`);
this.sendCompressError(state?.ws, 'SSH 会话未就绪', requestId);
return;
}
// 命令检查
const requiredCommand = format === 'zip' ? 'zip' : 'tar';
try {
const commandExists = await this.checkCommandExists(state, sessionId, requiredCommand); // 传递 sessionId
if (!commandExists) {
this.sendCompressError(state.ws, `命令 '${requiredCommand}' 在服务器上未找到`, requestId, `Command '${requiredCommand}' not found on server.`);
return;
}
} catch (checkError: any) {
this.sendCompressError(state.ws, `检查命令 '${requiredCommand}' 时出错`, requestId, checkError.message);
return;
}
console.debug(`[SFTP Compress ${sessionId}] Received request (ID: ${requestId}). Sources: ${sources.join(', ')}, Dest: ${destinationArchiveName}, Format: ${format}, Dir: ${targetDirectory}`);
// 构建目标压缩包的完整路径
const destinationArchivePath = pathModule.posix.join(targetDirectory, destinationArchiveName);
// --- 构建 Shell 命令 ---
let command: string;
// --- 修改:计算相对路径并引用 ---
const relativeSources = sources.map((s: string) => {
// 计算相对于 targetDirectory 的路径
const relativePath = pathModule.posix.relative(targetDirectory, s);
// 如果计算出的相对路径为空或'.', 表示源文件就在目标目录下,直接使用文件名
// 否则使用计算出的相对路径
return (relativePath === '' || relativePath === '.') ? pathModule.posix.basename(s) : relativePath;
});
const quotedRelativeSources = relativeSources.map((s: string) => `"${s.replace(/"/g, '\\"')}"`).join(' ');
// 确保目标目录和压缩包路径被正确引用
const quotedTargetDir = `"${targetDirectory.replace(/"/g, '\\"')}"`;
// const quotedDestPath = `"${destinationArchivePath.replace(/"/g, '\\"')}"`; // 目标路径在命令中不直接使用,使用相对名称
const quotedDestName = `"${destinationArchiveName.replace(/"/g, '\\"')}"`;
const cdCommand = `cd ${quotedTargetDir}`;
switch (format) {
case 'zip':
// zip -r [归档名] [源文件/目录列表]
// 需要在目标目录执行
command = `${cdCommand} && zip -r ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径
break;
case 'targz':
// tar -czvf [归档名] [源文件/目录列表]
// 需要在目标目录执行
command = `${cdCommand} && tar -czvf ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径
break;
case 'tarbz2':
// tar -cjvf [归档名] [源文件/目录列表]
// 需要在目标目录执行
command = `${cdCommand} && tar -cjvf ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径
break;
default:
this.sendCompressError(state.ws, `不支持的压缩格式: ${format}`, requestId);
return;
}
console.log(`[SFTP Compress ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
// --- 执行命令 ---
try {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[SFTP Compress ${sessionId}] Failed to start exec for compress (ID: ${requestId}):`, err);
this.sendCompressError(state.ws, `执行压缩命令失败: ${err.message}`, requestId);
return;
}
let stdoutData = '';
let stderrData = '';
let code: number | null = null; // Track exit code
stream.on('data', (data: Buffer) => {
stdoutData += data.toString();
// console.debug(`[SFTP Compress ${sessionId}] stdout: ${data.toString()}`);
});
stream.stderr.on('data', (data: Buffer) => {
stderrData += data.toString();
// console.debug(`[SFTP Compress ${sessionId}] stderr: ${data.toString()}`);
});
stream.on('close', (exitCode: number | null) => {
code = exitCode; // Store exit code
console.log(`[SFTP Compress ${sessionId}] Command finished with code ${code} (ID: ${requestId}). Stderr: ${stderrData.trim()}`);
if (code === 0 && !this.isErrorInStdErr(stderrData)) { // 检查退出码和 stderr
console.log(`[SFTP Compress ${sessionId}] Compression successful (ID: ${requestId}).`);
const successPayload: SftpCompressSuccessPayload = {
message: '压缩成功',
requestId: requestId
};
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({ type: 'sftp:compress:success', requestId: requestId, payload: successPayload })); // Ensure requestId is included
}
} else {
const errorDetails = stderrData.trim() || `压缩命令退出,代码: ${code ?? 'N/A'}`;
console.error(`[SFTP Compress ${sessionId}] Compression failed (ID: ${requestId}): ${errorDetails}`);
this.sendCompressError(state.ws, '压缩失败', requestId, errorDetails);
}
});
stream.on('error', (streamErr: Error) => {
console.error(`[SFTP Compress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr);
// 避免重复发送错误
if (!stderrData && code === undefined) { // 仅当 close 事件未触发且 stderr 为空时发送
this.sendCompressError(state.ws, '压缩命令流错误', requestId, streamErr.message);
}
});
});
} catch (execError: any) {
console.error(`[SFTP Compress ${sessionId}] Compress command caught unexpected error during exec setup (ID: ${requestId}):`, execError);
this.sendCompressError(state.ws, `执行压缩时发生意外错误: ${execError.message}`, requestId);
}
}
/**
* 解压远程服务器上的压缩文件
* @param sessionId 会话 ID
* @param payload 解压请求的 payload
*/
async decompress(sessionId: string, payload: SftpDecompressRequestPayload): Promise<void> {
const state = this.clientStates.get(sessionId);
const { archivePath, requestId } = payload;
if (!state || !state.sshClient) {
console.warn(`[SFTP Decompress] SSH 客户端未准备好,无法在 ${sessionId} 上执行 decompress (ID: ${requestId})`);
this.sendDecompressError(state?.ws, 'SSH 会话未就绪', requestId);
return;
}
const lowerArchivePath = archivePath.toLowerCase(); // 在此声明一次
// 命令检查
let requiredCommand = '';
// 使用已经声明的 lowerArchivePath
if (lowerArchivePath.endsWith('.zip')) {
requiredCommand = 'unzip';
} else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz') || lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) {
requiredCommand = 'tar';
} else {
this.sendDecompressError(state.ws, `不支持的压缩文件格式: ${archivePath}`, requestId);
return;
}
try {
const commandExists = await this.checkCommandExists(state, sessionId, requiredCommand); // 传递 sessionId
if (!commandExists) {
this.sendDecompressError(state.ws, `命令 '${requiredCommand}' 在服务器上未找到`, requestId, `Command '${requiredCommand}' not found on server.`);
return;
}
} catch (checkError: any) {
this.sendDecompressError(state.ws, `检查命令 '${requiredCommand}' 时出错`, requestId, checkError.message);
return;
}
console.debug(`[SFTP Decompress ${sessionId}] Received request for ${archivePath} (ID: ${requestId})`);
const extractDir = pathModule.posix.dirname(archivePath);
const archiveBasename = pathModule.posix.basename(archivePath);
// --- 构建 Shell 命令 ---
let command: string;
// 确保路径被正确引用
const quotedExtractDir = `"${extractDir.replace(/"/g, '\\"')}"`;
const quotedArchiveBasename = `"${archiveBasename.replace(/"/g, '\\"')}"`;
const cdCommand = `cd ${quotedExtractDir}`;
// 使用在方法开始处声明的 lowerArchivePath
if (lowerArchivePath.endsWith('.zip')) {
// unzip -o [压缩包名]
// 需要在目标目录执行
command = `${cdCommand} && unzip -o ${quotedArchiveBasename}`;
} else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz')) {
// tar -xzvf [压缩包名]
// 需要在目标目录执行
command = `${cdCommand} && tar -xzvf ${quotedArchiveBasename}`;
} else if (lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) {
// tar -xjvf [压缩包名]
// 需要在目标目录执行
command = `${cdCommand} && tar -xjvf ${quotedArchiveBasename}`;
} else {
this.sendDecompressError(state.ws, `不支持的压缩文件格式: ${archivePath}`, requestId);
return;
}
console.log(`[SFTP Decompress ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
// --- 执行命令 ---
try {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[SFTP Decompress ${sessionId}] Failed to start exec for decompress (ID: ${requestId}):`, err);
this.sendDecompressError(state.ws, `执行解压命令失败: ${err.message}`, requestId);
return;
}
let stdoutData = '';
let stderrData = '';
let code: number | null = null; // Track exit code
stream.on('data', (data: Buffer) => {
stdoutData += data.toString();
// console.debug(`[SFTP Decompress ${sessionId}] stdout: ${data.toString()}`);
});
stream.stderr.on('data', (data: Buffer) => {
stderrData += data.toString();
// console.debug(`[SFTP Decompress ${sessionId}] stderr: ${data.toString()}`);
});
stream.on('close', (exitCode: number | null) => {
code = exitCode; // Store exit code
console.log(`[SFTP Decompress ${sessionId}] Command finished with code ${code} (ID: ${requestId}). Stderr: ${stderrData.trim()}`);
if (code === 0 && !this.isErrorInStdErr(stderrData)) { // 检查退出码和 stderr
console.log(`[SFTP Decompress ${sessionId}] Decompression successful (ID: ${requestId}).`);
const successPayload: SftpDecompressSuccessPayload = {
message: '解压成功',
requestId: requestId
};
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({ type: 'sftp:decompress:success', requestId: requestId, payload: successPayload })); // Ensure requestId is included
}
} else {
const errorDetails = stderrData.trim() || `解压命令退出,代码: ${code ?? 'N/A'}`;
console.error(`[SFTP Decompress ${sessionId}] Decompression failed (ID: ${requestId}): ${errorDetails}`);
this.sendDecompressError(state.ws, '解压失败', requestId, errorDetails);
}
});
stream.on('error', (streamErr: Error) => {
console.error(`[SFTP Decompress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr);
// 避免重复发送错误
if (!stderrData && code === undefined) { // 仅当 close 事件未触发且 stderr 为空时发送
this.sendDecompressError(state.ws, '解压命令流错误', requestId, streamErr.message);
}
});
});
} catch (execError: any) {
console.error(`[SFTP Decompress ${sessionId}] Decompress command caught unexpected error during exec setup (ID: ${requestId}):`, execError);
this.sendDecompressError(state.ws, `执行解压时发生意外错误: ${execError.message}`, requestId);
}
}
// --- 辅助方法 ---
/** 检查远程服务器上是否存在指定的命令 */
private checkCommandExists(state: ClientState, sessionId: string, commandName: string): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!state.sshClient) {
return reject(new Error('SSH client is not available.'));
}
// 优先使用 command -v, 其次 which
const checkCommands = [`command -v ${commandName}`, `which ${commandName}`];
let currentCheckIndex = 0;
const tryCommand = () => {
if (currentCheckIndex >= checkCommands.length) {
resolve(false); // 所有检查命令都尝试过了,未找到
return;
}
const checkCmd = checkCommands[currentCheckIndex];
console.log(`[SFTP Command Check ${sessionId}] Executing: ${checkCmd}`);
state.sshClient.exec(checkCmd, (err, stream) => {
if (err) {
console.error(`[SFTP Command Check ${sessionId}] Failed to start exec for "${checkCmd}":`, err);
currentCheckIndex++;
tryCommand(); // 尝试下一个检查命令
return;
}
let output = '';
stream.on('data', (data: Buffer) => {
output += data.toString();
});
stream.on('close', (code: number | null) => {
if (code === 0 && output.trim() !== '') {
console.log(`[SFTP Command Check ${sessionId}] Command '${commandName}' found using "${checkCmd}". Output: ${output.trim()}`);
resolve(true);
} else {
console.log(`[SFTP Command Check ${sessionId}] Command '${commandName}' not found with "${checkCmd}" (code: ${code}, output: "${output.trim()}").`);
currentCheckIndex++;
tryCommand(); // 尝试下一个检查命令
}
});
stream.stderr.on('data', (data: Buffer) => {
// console.debug(`[SFTP Command Check ${sessionId}] stderr for "${checkCmd}": ${data.toString()}`);
});
stream.on('error', (streamErr: Error) => {
console.error(`[SFTP Command Check ${sessionId}] Stream error for "${checkCmd}":`, streamErr);
currentCheckIndex++;
tryCommand(); // 尝试下一个检查命令
});
});
};
tryCommand();
});
}
/** 发送压缩错误消息 */
private sendCompressError(ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string): void {
if (ws && ws.readyState === WebSocket.OPEN) {
const payload: SftpCompressErrorPayload = { error, requestId };
if (details) payload.details = details;
// 检查是否是命令未找到的特定错误
if (error.includes('在服务器上未找到')) {
ws.send(JSON.stringify({ type: 'sftp:command_not_found', payload: { operation: 'compress', command: error.match(/'([^']+)'/)?.[1] || 'unknown', message: details || error }, requestId }));
} else {
ws.send(JSON.stringify({ type: 'sftp:compress:error', payload }));
}
} else {
console.warn(`[SFTP Compress] WebSocket closed or invalid, cannot send error for request ${requestId}.`);
}
}
/** 发送解压错误消息 */
private sendDecompressError(ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string): void {
if (ws && ws.readyState === WebSocket.OPEN) {
const payload: SftpDecompressErrorPayload = { error, requestId };
if (details) payload.details = details;
// 检查是否是命令未找到的特定错误
if (error.includes('在服务器上未找到')) {
ws.send(JSON.stringify({ type: 'sftp:command_not_found', payload: { operation: 'decompress', command: error.match(/'([^']+)'/)?.[1] || 'unknown', message: details || error }, requestId }));
} else {
ws.send(JSON.stringify({ type: 'sftp:decompress:error', payload }));
}
} else {
console.warn(`[SFTP Decompress] WebSocket closed or invalid, cannot send error for request ${requestId}.`);
}
}
/** 检查 stderr 输出是否包含表示错误的常见模式 */
private isErrorInStdErr(stderr: string): boolean {
if (!stderr || stderr.trim().length === 0) {
return false; // 空 stderr 不是错误
}
const lowerStderr = stderr.toLowerCase();
// 常见的错误关键词或模式
const errorPatterns = [
'error', 'fail', 'cannot', 'not found', 'no such file', 'permission denied', 'invalid', '不支持'
];
// tar/zip 进度信息通常包含百分比或文件名,不应视为错误
if (/[\d.]+%/.test(stderr) || /adding:/.test(lowerStderr) || /inflating:/.test(lowerStderr) || /extracting:/.test(lowerStderr)) {
// 忽略一些明确的非错误输出
if (errorPatterns.some(pattern => lowerStderr.includes(pattern))) {
// 如果进度信息中包含错误关键词,则可能真的是错误
return true;
}
return false;
}
return errorPatterns.some(pattern => lowerStderr.includes(pattern));
}
// --- File Upload Methods ---
/** Start a new file upload */
@@ -1053,8 +1429,7 @@ export class SftpService {
return; // Stop the upload process
}
}
// --- 结束新增 ---
// --- 预检查文件是否可写 ---
console.log(`[SFTP Upload ${uploadId}] Pre-checking writability for: ${remotePath}`);
try {
@@ -1083,8 +1458,7 @@ export class SftpService {
state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `文件不可写或创建失败: ${preCheckError.message}` } }));
return; // Stop if pre-check fails
}
// --- 结束新增 ---
console.log(`[SFTP Upload ${uploadId}] Creating write stream for: ${remotePath}`);
// 确保 state.sftp 存在
+298 -7
View File
@@ -1,10 +1,11 @@
import { Request, Response } from 'express';
import path from 'path';
import { clientStates, ClientState } from '../websocket'; // +++ 导入 ClientState +++
import * as archiver from 'archiver'; // +++ 引入 archiver +++ (尝试修复 TS2349)
import { SFTPWrapper, Stats } from 'ssh2'; // +++ 移除 Dirent 导入 +++
// 移除 ssh2-streams 导入
import { clientStates } from '../websocket';
import * as archiver from 'archiver';
import { SFTPWrapper, Stats } from 'ssh2';
import { WebSocket } from 'ws';
import { ClientState, AuthenticatedWebSocket } from '../websocket/types';
import { SftpCompressRequestPayload, SftpDecompressRequestPayload, SftpCompressSuccessPayload, SftpCompressErrorPayload, SftpDecompressSuccessPayload, SftpDecompressErrorPayload } from '../websocket/types'; // Import payload types
/**
* 处理文件下载请求 (GET /api/v1/sftp/download)
*/
@@ -51,7 +52,7 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
}
const userSftpSession = targetState.sftp; // 获取正确的 SFTP 实例
// --- 结束修改 ---
try {
// 获取文件状态以确定文件大小(可选,但有助于设置 Content-Length
@@ -158,7 +159,7 @@ export const downloadDirectory = async (req: Request, res: Response): Promise<vo
}
const userSftpSession = targetState.sftp; // 获取正确的 SFTP 实例
// --- 结束修改 ---
try {
// 1. 验证路径是否为目录
@@ -271,3 +272,293 @@ export const downloadDirectory = async (req: Request, res: Response): Promise<vo
};
// --- WebSocket Message Handlers (to be called by WebSocket router) ---
/**
* 发送通用 WebSocket 错误消息的辅助函数
*/
const sendWebSocketError = (ws: AuthenticatedWebSocket | undefined, type: string, message: string, requestId: string, details?: any) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, payload: { error: message, details, requestId } }));
} else {
console.warn(`WebSocket closed or invalid, cannot send error for request ${requestId}. Type: ${type}, Message: ${message}`);
}
};
/**
* 发送压缩错误消息
*/
const sendCompressError = (ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string) => {
const payload: SftpCompressErrorPayload = { error, requestId };
if (details) payload.details = details;
sendWebSocketError(ws, 'sftp:compress:error', error, requestId, payload);
};
/**
* 发送解压错误消息
*/
const sendDecompressError = (ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string) => {
const payload: SftpDecompressErrorPayload = { error, requestId };
if (details) payload.details = details;
sendWebSocketError(ws, 'sftp:decompress:error', error, requestId, payload);
};
/**
* 检查 stderr 输出是否包含表示错误的常见模式 (从 SftpService 复制过来)
*/
const isErrorInStdErr = (stderr: string): boolean => {
if (!stderr || stderr.trim().length === 0) {
return false; // 空 stderr 不是错误
}
const lowerStderr = stderr.toLowerCase();
// 常见的错误关键词或模式
const errorPatterns = [
'error', 'fail', 'cannot', 'not found', 'no such file', 'permission denied', 'invalid', '不支持'
];
// tar/zip 进度信息通常包含百分比或文件名,不应视为错误
if (/[\d.]+%/.test(stderr) || /adding:/.test(lowerStderr) || /inflating:/.test(lowerStderr) || /extracting:/.test(lowerStderr)) {
// 忽略一些明确的非错误输出
if (errorPatterns.some(pattern => lowerStderr.includes(pattern))) {
// 如果进度信息中包含错误关键词,则可能真的是错误
return true;
}
return false;
}
return errorPatterns.some(pattern => lowerStderr.includes(pattern));
};
/**
* 处理 'sftp:compress' WebSocket 消息
* @param ws WebSocket 连接实例
* @param payload 消息负载
*/
export const handleCompressRequest = async (ws: AuthenticatedWebSocket, payload: SftpCompressRequestPayload): Promise<void> => {
const { sources, destinationArchiveName, format, targetDirectory, requestId } = payload;
const sessionId = ws.sessionId; // 从 AuthenticatedWebSocket 获取 sessionId
if (!sessionId) {
console.error(`[WS SFTP Compress] Missing sessionId on WebSocket for request (ID: ${requestId}).`);
sendCompressError(ws, '内部错误:缺少会话 ID', requestId);
return;
}
const state = clientStates.get(sessionId);
console.log(`[WS SFTP Compress ${sessionId}] Received request (ID: ${requestId}).`);
if (!state || !state.sshClient) {
console.warn(`[WS SFTP Compress ${sessionId}] SSH client not ready (ID: ${requestId})`);
sendCompressError(ws, 'SSH 会话未就绪', requestId);
return;
}
console.debug(`[WS SFTP Compress ${sessionId}] Processing compress request (ID: ${requestId}). Sources: ${sources.join(', ')}, Dest: ${destinationArchiveName}, Format: ${format}, Dir: ${targetDirectory}`);
// 构建目标压缩包的完整路径 (使用 posix 风格)
const destinationArchivePath = path.posix.join(targetDirectory, destinationArchiveName);
// --- 构建 Shell 命令 ---
let command: string;
// 确保源路径被正确引用,特别是包含空格或特殊字符时
// 注意:源路径是相对于 targetDirectory 的
const quotedSources = sources.map((s: string) => `"${s.replace(/"/g, '\\"')}"`).join(' ');
// 确保目标目录和压缩包名称被正确引用
const quotedTargetDir = `"${targetDirectory.replace(/"/g, '\\"')}"`;
const quotedDestName = `"${destinationArchiveName.replace(/"/g, '\\"')}"`;
const cdCommand = `cd ${quotedTargetDir}`;
switch (format) {
case 'zip':
// zip -r [归档名] [源文件/目录列表]
command = `${cdCommand} && zip -qr ${quotedDestName} ${quotedSources}`; // -q for quiet to reduce stderr noise
break;
case 'targz':
// tar -czvf [归档名] [源文件/目录列表]
command = `${cdCommand} && tar -czf ${quotedDestName} ${quotedSources}`; // removed -v for less noise
break;
case 'tarbz2':
// tar -cjvf [归档名] [源文件/目录列表]
command = `${cdCommand} && tar -cjf ${quotedDestName} ${quotedSources}`; // removed -v for less noise
break;
default:
sendCompressError(ws, `不支持的压缩格式: ${format}`, requestId);
return;
}
console.log(`[WS SFTP Compress ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
// --- 执行命令 ---
try {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[WS SFTP Compress ${sessionId}] Failed to start exec (ID: ${requestId}):`, err);
sendCompressError(ws, `执行压缩命令失败: ${err.message}`, requestId);
return;
}
let stderrData = '';
let stdoutData = ''; // Capture stdout for debugging if needed
let exitCode: number | null = null;
stream.on('data', (data: Buffer) => {
stdoutData += data.toString();
// console.debug(`[WS SFTP Compress ${sessionId}] stdout: ${data}`);
});
stream.stderr.on('data', (data: Buffer) => {
stderrData += data.toString();
console.debug(`[WS SFTP Compress ${sessionId}] stderr: ${data}`); // Log stderr for debugging
});
stream.on('close', (code: number | null) => {
exitCode = code;
console.log(`[WS SFTP Compress ${sessionId}] Command finished with code ${exitCode} (ID: ${requestId}). Stderr length: ${stderrData.length}`);
if (exitCode === 0 && !isErrorInStdErr(stderrData)) {
console.log(`[WS SFTP Compress ${sessionId}] Compression successful (ID: ${requestId}).`);
const successPayload: SftpCompressSuccessPayload = {
message: '压缩成功',
requestId: requestId,
// Optionally add archive path or details here
// archivePath: destinationArchivePath
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'sftp:compress:success', payload: successPayload }));
}
} else {
const errorDetails = stderrData.trim() || `压缩命令退出,代码: ${exitCode ?? 'N/A'}`;
console.error(`[WS SFTP Compress ${sessionId}] Compression failed (ID: ${requestId}): ${errorDetails}`);
sendCompressError(ws, '压缩失败', requestId, errorDetails);
}
});
stream.on('error', (streamErr: Error) => {
console.error(`[WS SFTP Compress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr);
// Avoid sending duplicate errors if 'close' already indicated failure
if (exitCode === null) {
sendCompressError(ws, '压缩命令流错误', requestId, streamErr.message);
}
});
});
} catch (execError: any) {
console.error(`[WS SFTP Compress ${sessionId}] Unexpected error setting up exec (ID: ${requestId}):`, execError);
sendCompressError(ws, `执行压缩时发生意外错误: ${execError.message}`, requestId);
}
};
/**
* 处理 'sftp:decompress' WebSocket 消息
* @param ws WebSocket 连接实例
* @param payload 消息负载
*/
export const handleDecompressRequest = async (ws: AuthenticatedWebSocket, payload: SftpDecompressRequestPayload): Promise<void> => {
const { archivePath, requestId } = payload;
const sessionId = ws.sessionId;
if (!sessionId) {
console.error(`[WS SFTP Decompress] Missing sessionId on WebSocket for request (ID: ${requestId}).`);
sendDecompressError(ws, '内部错误:缺少会话 ID', requestId);
return;
}
const state = clientStates.get(sessionId);
console.log(`[WS SFTP Decompress ${sessionId}] Received request for ${archivePath} (ID: ${requestId}).`);
if (!state || !state.sshClient) {
console.warn(`[WS SFTP Decompress ${sessionId}] SSH client not ready (ID: ${requestId})`);
sendDecompressError(ws, 'SSH 会话未就绪', requestId);
return;
}
console.debug(`[WS SFTP Decompress ${sessionId}] Processing decompress request for ${archivePath} (ID: ${requestId})`);
const extractDir = path.posix.dirname(archivePath);
const archiveBasename = path.posix.basename(archivePath);
// --- 构建 Shell 命令 ---
let command: string;
// 确保路径被正确引用
const quotedExtractDir = `"${extractDir.replace(/"/g, '\\"')}"`;
const quotedArchiveBasename = `"${archiveBasename.replace(/"/g, '\\"')}"`;
const cdCommand = `cd ${quotedExtractDir}`;
const lowerArchivePath = archivePath.toLowerCase();
if (lowerArchivePath.endsWith('.zip')) {
// unzip -o [压缩包名]
command = `${cdCommand} && unzip -oq ${quotedArchiveBasename}`; // -o: overwrite, -q: quiet
} else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz')) {
// tar -xzvf [压缩包名]
command = `${cdCommand} && tar -xzf ${quotedArchiveBasename}`; // removed -v
} else if (lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) {
// tar -xjvf [压缩包名]
command = `${cdCommand} && tar -xjf ${quotedArchiveBasename}`; // removed -v
} else {
sendDecompressError(ws, `不支持的压缩文件格式: ${archivePath}`, requestId);
return;
}
console.log(`[WS SFTP Decompress ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
// --- 执行命令 ---
try {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[WS SFTP Decompress ${sessionId}] Failed to start exec (ID: ${requestId}):`, err);
sendDecompressError(ws, `执行解压命令失败: ${err.message}`, requestId);
return;
}
let stderrData = '';
let stdoutData = '';
let exitCode: number | null = null;
stream.on('data', (data: Buffer) => {
stdoutData += data.toString();
// console.debug(`[WS SFTP Decompress ${sessionId}] stdout: ${data}`);
});
stream.stderr.on('data', (data: Buffer) => {
stderrData += data.toString();
console.debug(`[WS SFTP Decompress ${sessionId}] stderr: ${data}`); // Log stderr
});
stream.on('close', (code: number | null) => {
exitCode = code;
console.log(`[WS SFTP Decompress ${sessionId}] Command finished with code ${exitCode} (ID: ${requestId}). Stderr length: ${stderrData.length}`);
if (exitCode === 0 && !isErrorInStdErr(stderrData)) {
console.log(`[WS SFTP Decompress ${sessionId}] Decompression successful (ID: ${requestId}).`);
const successPayload: SftpDecompressSuccessPayload = {
message: '解压成功',
requestId: requestId,
// Optionally add target directory
// targetDirectory: extractDir
};
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'sftp:decompress:success', payload: successPayload }));
}
} else {
const errorDetails = stderrData.trim() || `解压命令退出,代码: ${exitCode ?? 'N/A'}`;
console.error(`[WS SFTP Decompress ${sessionId}] Decompression failed (ID: ${requestId}): ${errorDetails}`);
sendDecompressError(ws, '解压失败', requestId, errorDetails);
}
});
stream.on('error', (streamErr: Error) => {
console.error(`[WS SFTP Decompress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr);
if (exitCode === null) {
sendDecompressError(ws, '解压命令流错误', requestId, streamErr.message);
}
});
});
} catch (execError: any) {
console.error(`[WS SFTP Decompress ${sessionId}] Unexpected error setting up exec (ID: ${requestId}):`, execError);
sendDecompressError(ws, `执行解压时发生意外错误: ${execError.message}`, requestId);
}
};
+2 -2
View File
@@ -117,6 +117,8 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ
case 'sftp:realpath':
case 'sftp:copy':
case 'sftp:move':
case 'sftp:compress':
case 'sftp:decompress':
await handleSftpOperation(ws, type, payload, requestId);
break;
@@ -132,8 +134,6 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ
break;
// --- SSH Suspend Cases ---
// 旧的 SSH_SUSPEND_START 逻辑已被新的 SSH_MARK_FOR_SUSPEND 和 SshSuspendService.takeOverMarkedSession 取代
// case 'SSH_SUSPEND_START': { ... } // Removed
case 'SSH_SUSPEND_LIST_REQUEST': {
if (!ws.userId) {
@@ -84,6 +84,35 @@ export async function handleSftpOperation(
sftpService.move(sessionId, payload.sources, payload.destination, requestId);
} else throw new Error("Missing 'sources' (array) or 'destination' in payload for move");
break;
case 'sftp:compress':
if (Array.isArray(payload?.sources) && payload?.destination && payload?.format && requestId) {
const destinationPath = payload.destination as string;
// 从 destinationPath 中提取 targetDirectory 和 destinationArchiveName
// pathModule.posix 总是使用 / 作为分隔符
const pathModule = await import('path'); // 动态导入 path 模块
const targetDirectory = pathModule.posix.dirname(destinationPath);
const destinationArchiveName = pathModule.posix.basename(destinationPath);
const compressPayload = {
sources: payload.sources as string[],
destinationArchiveName: destinationArchiveName,
format: payload.format as 'zip' | 'targz' | 'tarbz2',
targetDirectory: targetDirectory,
requestId: requestId
};
sftpService.compress(sessionId, compressPayload);
} else throw new Error("Missing 'sources' (array), 'destination', 'format', or 'requestId' in payload for compress");
break;
case 'sftp:decompress':
if (payload?.source && requestId) {
const decompressPayload = {
archivePath: payload.source as string,
// destinationDirectory: payload.destination as string, // sftpService.decompress 目前不使用此参数
requestId: requestId
};
sftpService.decompress(sessionId, decompressPayload);
} else throw new Error("Missing 'source' or 'requestId' in payload for decompress");
break;
default:
console.warn(`WebSocket: Received unhandled SFTP message type in sftp.handler: ${type}`);
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'sftp_error', payload: { message: `内部未处理的 SFTP 类型: ${type}`, requestId } }));
+43 -1
View File
@@ -251,4 +251,46 @@ export type SshSuspendServerToClientMessages =
// export type WebSocketMessage = BaseMessageType | SshSuspendClientToServerMessages | OtherFeatureMessages;
// And for outgoing:
// export type WebSocketResponse = BaseResponseType | SshSuspendServerToClientMessages | OtherFeatureResponses;
// This part depends on the existing structure, so I'm providing the specific types for now.
// This part depends on the existing structure, so I'm providing the specific types for now.
// --- SFTP Compress/Decompress Message Types ---
// C -> S: Request to compress files/directories
export interface SftpCompressRequestPayload {
sources: string[]; // Array of source paths (relative to targetDirectory)
destinationArchiveName: string; // Desired name for the archive file
format: 'zip' | 'targz' | 'tarbz2'; // Archive format
targetDirectory: string; // The directory where sources are located and where the archive will be created
requestId: string;
}
// S -> C: Compression success
export interface SftpCompressSuccessPayload {
message: string;
requestId: string;
}
// S -> C: Compression error
export interface SftpCompressErrorPayload {
error: string;
details?: string; // Stderr output or specific error details
requestId: string;
}
// C -> S: Request to decompress an archive
export interface SftpDecompressRequestPayload {
archivePath: string; // Full path to the archive file
requestId: string;
}
// S -> C: Decompression success
export interface SftpDecompressSuccessPayload {
message: string;
requestId: string;
}
// S -> C: Decompression error
export interface SftpDecompressErrorPayload {
error: string;
details?: string; // Stderr output or specific error details
requestId: string;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function initializeUpgradeHandler(
// 确保 ipAddress 不是 undefined 或空字符串,否则设为 'unknown'
ipAddress = ipAddress || 'unknown';
console.log(`[WebSocket Upgrade] Determined IP Address: ${ipAddress}`);
// --- 结束修改 ---
console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}, Path: ${pathname}`); // 使用新获取的 ipAddress