feat(remote): 完善文件传输功能及WebSocket支持
This commit is contained in:
@@ -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) {
|
||||
|
||||
261
remote/src/server/FileHandler.js
Normal file
261
remote/src/server/FileHandler.js
Normal 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;
|
||||
78
remote/src/server/FileServer.js
Normal file
78
remote/src/server/FileServer.js
Normal 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;
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user