feat(remote): 完善文件传输功能及WebSocket支持

This commit is contained in:
2026-03-10 01:41:02 +08:00
parent 6d5520dfa5
commit 84e455d9a6
19 changed files with 1263 additions and 5 deletions

View File

@@ -68,6 +68,17 @@ router.get('/browse', (req, res) => {
try {
const filePath = req.query.path || '';
const allowSystem = req.query.allowSystem === 'true';
if (allowSystem && !filePath) {
const drives = fileService.getDrives();
res.json({
items: drives,
currentPath: '',
parentPath: null
});
return;
}
const result = fileService.browseDirectory(filePath, allowSystem);
res.json(result);
} catch (error) {

View File

@@ -0,0 +1,261 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
const MessageTypes = require('./messageTypes');
const { fileService } = require('../services/file');
const CHUNK_SIZE = 5 * 1024 * 1024;
class FileHandler {
constructor() {
this.uploadSessions = new Map();
}
handleMessage(message, ws) {
const { type, requestId, ...data } = message;
switch (type) {
case MessageTypes.FILE_LIST_GET:
this.handleFileList(ws, requestId);
break;
case MessageTypes.FILE_BROWSE:
this.handleFileBrowse(ws, data, requestId);
break;
case MessageTypes.FILE_UPLOAD_START:
this.handleFileUploadStart(ws, data, requestId);
break;
case MessageTypes.FILE_UPLOAD_CHUNK:
this.handleFileUploadChunk(ws, data, requestId);
break;
case MessageTypes.FILE_UPLOAD_MERGE:
this.handleFileUploadMerge(ws, data, requestId);
break;
case MessageTypes.FILE_DOWNLOAD_START:
this.handleFileDownload(ws, data, requestId);
break;
case MessageTypes.FILE_DELETE:
this.handleFileDelete(ws, data, requestId);
break;
default:
logger.debug('Unknown file message type', { type });
}
}
sendResponse(ws, type, requestId, payload) {
ws.send(JSON.stringify({
type,
requestId,
...payload
}));
}
handleFileList(ws, requestId) {
try {
const files = fileService.getFileList();
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files });
} catch (error) {
logger.error('Failed to get file list', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files: [], error: error.message });
}
}
handleFileBrowse(ws, data, requestId) {
try {
const { path: dirPath, allowSystem } = data;
if (allowSystem && !dirPath) {
const drives = fileService.getDrives();
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, {
items: drives,
currentPath: '',
parentPath: null
});
return;
}
const result = fileService.browseDirectory(dirPath || '', allowSystem === true);
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, result);
} catch (error) {
logger.error('Failed to browse directory', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, { items: [], error: error.message });
}
}
handleFileUploadStart(ws, data, requestId) {
try {
const { filename, totalChunks, fileSize } = data;
const fileId = data.fileId || requestId || crypto.randomBytes(16).toString('hex');
this.uploadSessions.set(fileId, {
filename,
totalChunks,
fileSize,
chunks: new Map(),
createdAt: Date.now()
});
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_START, requestId, {
fileId,
chunkSize: CHUNK_SIZE,
message: 'Upload session started'
});
} catch (error) {
logger.error('Failed to start upload', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
}
}
handleFileUploadChunk(ws, data, requestId) {
try {
const { fileId, chunkIndex } = data;
const session = this.uploadSessions.get(fileId);
if (!session) {
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
return;
}
let chunkData;
if (data.data) {
chunkData = Buffer.from(data.data, 'base64');
} else if (data.buffer) {
chunkData = Buffer.from(data.buffer);
}
if (!chunkData) {
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'No chunk data provided' });
return;
}
session.chunks.set(chunkIndex, chunkData);
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_CHUNK, requestId, { success: true, chunkIndex });
} catch (error) {
logger.error('Failed to upload chunk', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
}
}
handleFileUploadMerge(ws, data, requestId) {
try {
const { fileId, filename } = data;
const session = this.uploadSessions.get(fileId);
if (!session) {
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
return;
}
const success = fileService.mergeChunks(fileId, session.totalChunks, filename);
this.uploadSessions.delete(fileId);
if (success) {
fileService.cleanupChunks(fileId);
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: true, filename });
} else {
fileService.cleanupChunks(fileId);
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Failed to merge chunks' });
}
} catch (error) {
logger.error('Failed to merge chunks', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
}
}
handleFileDownload(ws, data, requestId) {
try {
const { filename, filePath, allowSystem } = data;
let fullFilePath;
if (allowSystem && filePath && (path.isAbsolute(filePath) || filePath.includes(':') || filePath.startsWith('\\') || filePath.startsWith('/'))) {
if (path.isAbsolute(filePath)) {
fullFilePath = filePath;
} else {
fullFilePath = filePath.replace(/\//g, '\\');
}
} else if (filePath) {
fullFilePath = path.join(fileService.uploadDir, filePath);
} else {
fullFilePath = path.join(fileService.uploadDir, filename);
}
if (!fs.existsSync(fullFilePath)) {
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: 'File not found: ' + fullFilePath });
return;
}
const stat = fs.statSync(fullFilePath);
const fileSize = stat.size;
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_START, requestId, {
filename,
size: fileSize,
chunkSize: CHUNK_SIZE,
totalChunks
});
const stream = fs.createReadStream(fullFilePath);
let chunkIndex = 0;
stream.on('data', (chunk) => {
ws.send(JSON.stringify({
type: MessageTypes.FILE_DOWNLOAD_CHUNK,
chunkIndex,
data: chunk.toString('base64'),
progress: Math.round(((chunkIndex + 1) / totalChunks) * 100)
}));
chunkIndex++;
});
stream.on('end', () => {
ws.send(JSON.stringify({
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
success: true,
filename
}));
});
stream.on('error', (error) => {
logger.error('File download error', { error: error.message });
ws.send(JSON.stringify({
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
success: false,
error: error.message
}));
});
} catch (error) {
logger.error('Failed to start download', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: error.message });
}
}
handleFileDelete(ws, data, requestId) {
try {
const { filename, filePath } = data;
const targetPath = filePath || '';
const success = fileService.deleteFile(filename, targetPath);
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success, filename });
} catch (error) {
logger.error('Failed to delete file', { error: error.message });
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success: false, error: error.message });
}
}
cleanup() {
const now = Date.now();
const maxAge = 30 * 60 * 1000;
for (const [fileId, session] of this.uploadSessions) {
if (now - session.createdAt > maxAge) {
this.uploadSessions.delete(fileId);
}
}
}
}
module.exports = FileHandler;

View File

@@ -0,0 +1,78 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const logger = require('../utils/logger');
const FileHandler = require('./FileHandler');
class FileServer {
constructor(port = 3001) {
this.port = port;
this.app = express();
this.server = http.createServer(this.app);
this.wss = null;
this.fileHandler = new FileHandler();
}
start() {
return new Promise((resolve, reject) => {
this.server.listen({ port: this.port, host: '0.0.0.0' }, () => {
logger.info('File server started', { port: this.port });
this._setupWebSocket();
resolve({ port: this.port });
});
this.server.on('error', reject);
});
}
_setupWebSocket() {
this.wss = new WebSocket.Server({ server: this.server, path: '/ws' });
this.wss.on('connection', (ws, req) => {
logger.info('File client connected', { ip: req.socket.remoteAddress });
ws.on('message', (data, isBinary) => {
try {
if (isBinary) {
logger.warn('Received binary data on file WebSocket, ignoring');
return;
}
const dataStr = data.toString();
logger.info('Raw message received:', dataStr.substring(0, 300));
const message = JSON.parse(dataStr);
logger.info('File message parsed', { type: message.type, requestId: message.requestId });
this.fileHandler.handleMessage(message, ws);
} catch (error) {
logger.error('Failed to parse file message', { error: error.message });
}
});
ws.on('close', () => {
logger.info('File client disconnected');
});
ws.on('error', (error) => {
logger.error('File WebSocket error', { error: error.message });
});
});
this.wss.on('error', (error) => {
logger.error('File WebSocket server error', { error: error.message });
});
}
stop() {
return new Promise((resolve, reject) => {
if (this.wss) {
this.wss.clients.forEach(client => client.close());
this.wss.close();
}
this.server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
}
module.exports = FileServer;

View File

@@ -44,7 +44,8 @@ class FileService {
}
getFilePath(filename) {
const filePath = path.join(this.uploadDir, path.basename(filename));
if (!filename) return null;
const filePath = path.normalize(filename);
if (!fs.existsSync(filePath)) {
return null;
}
@@ -92,7 +93,13 @@ class FileService {
mergeChunks(fileId, totalChunks, filename) {
try {
const filePath = path.join(this.uploadDir, path.basename(filename));
const filePath = path.normalize(filename);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const fd = fs.openSync(filePath, 'w');
for (let i = 0; i < totalChunks; i++) {