From 0c443b029d2f17f541b668298f098ed9f583b651 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Tue, 10 Mar 2026 16:15:46 +0800 Subject: [PATCH] Enhance security and reliability across SFTP workflows --- AGENTS.md | 190 +++++ .../sftp/manager/SftpManagerApplication.java | 2 + .../com/sftp/manager/config/WebConfig.java | 11 +- .../controller/ConnectionController.java | 64 +- .../manager/controller/FileController.java | 654 ++++++++++-------- .../sftp/manager/dto/BatchDeleteRequest.java | 11 + .../sftp/manager/dto/DirectoryRequest.java | 9 + .../sftp/manager/dto/DisconnectRequest.java | 8 + .../com/sftp/manager/dto/RenameRequest.java | 10 + .../exception/GlobalExceptionHandler.java | 41 ++ .../com/sftp/manager/model/Connection.java | 3 + .../manager/service/ConnectionService.java | 168 ++++- .../manager/service/LocalFileService.java | 164 ++++- .../sftp/manager/service/SessionManager.java | 64 +- .../com/sftp/manager/service/SftpService.java | 22 +- src/main/resources/application-prod.yml | 5 + src/main/resources/application.yml | 5 + src/main/resources/static/js/app.js | 6 +- .../controller/ConnectionControllerTest.java | 78 +++ .../controller/FileControllerTest.java | 83 +++ .../service/ConnectionServiceTest.java | 115 +++ .../manager/service/LocalFileServiceTest.java | 77 +++ .../manager/service/SessionManagerTest.java | 81 +++ 23 files changed, 1477 insertions(+), 394 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/DirectoryRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/DisconnectRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/RenameRequest.java create mode 100644 src/main/java/com/sftp/manager/exception/GlobalExceptionHandler.java create mode 100644 src/test/java/com/sftp/manager/controller/ConnectionControllerTest.java create mode 100644 src/test/java/com/sftp/manager/controller/FileControllerTest.java create mode 100644 src/test/java/com/sftp/manager/service/ConnectionServiceTest.java create mode 100644 src/test/java/com/sftp/manager/service/LocalFileServiceTest.java create mode 100644 src/test/java/com/sftp/manager/service/SessionManagerTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fbe7698 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,190 @@ +# `sftp-manager` AGENTS 指南 + +本文件面向在本仓库内执行任务的自动化 Coding Agent。 +请将其作为本项目在构建、测试、编码规范方面的本地权威说明。 + +## 1)项目概览 + +- 技术栈:Spring Boot 2.7.18、Java 8、Maven、Spring Data JPA、H2、JSch、原生 HTML/CSS/JS。 +- 启动入口:`src/main/java/com/sftp/manager/SftpManagerApplication.java`。 +- 主要目录结构: + - `src/main/java/com/sftp/manager/controller/`:HTTP 控制器 + - `src/main/java/com/sftp/manager/service/`:业务逻辑(SFTP、本地文件) + - `src/main/java/com/sftp/manager/model/`:实体模型 + - `src/main/java/com/sftp/manager/repository/`:JPA 仓库 + - `src/main/java/com/sftp/manager/dto/`:接口请求/响应 DTO + - `src/main/resources/static/`:前端静态资源 + - `src/main/resources/application*.yml`:运行配置 +- 默认端口:`48081`(见 `application.yml`)。 +- 生产环境可启用 `prod` profile(见 `application-prod.yml`)。 + +## 2)Cursor / Copilot 规则检查结果 + +已检查以下路径,当前未发现规则文件: + +- `.cursor/rules/` +- `.cursorrules` +- `.github/copilot-instructions.md` + +若后续新增上述文件,请立即同步更新本 `AGENTS.md`。 +新增规则应视为更高优先级约束并优先遵循。 + +## 3)构建、运行、测试命令 + +默认在仓库根目录执行命令。 + +### 3.1 Maven 构建与打包 + +- 仅编译:`mvn clean compile` +- 完整打包(含测试):`mvn clean package` +- 跳过测试打包:`mvn clean package -DskipTests` +- 安装到本地仓库:`mvn clean install` + +### 3.2 本地运行 + +- 开发运行:`mvn spring-boot:run` +- 运行 JAR:`java -jar target/sftp-manager-1.0.0.jar` +- 生产配置运行:`java -jar target/sftp-manager-1.0.0.jar --spring.profiles.active=prod` + +### 3.3 Docker 运行 + +- Linux/Mac 一键启动:`./run.sh` +- Windows 一键启动:`run.bat` +- 手动启动:`docker compose up -d --build` +- 手动停止:`docker compose down` + +### 3.4 测试命令(重点:单测定向执行) + +当前仓库尚无 `src/test/**/*.java` 文件。 +当新增测试后,按以下方式执行: + +- 执行全部测试:`mvn test` +- 执行单个测试类:`mvn -Dtest=ConnectionServiceTest test` +- 执行单个测试方法:`mvn -Dtest=ConnectionServiceTest#connect_success test` +- 执行多个测试类:`mvn -Dtest=ConnectionServiceTest,FileControllerTest test` + +若出现“测试未执行”,优先检查命名是否满足 Surefire 默认规则: + +- `*Test` +- `*Tests` +- `*TestCase` + +### 3.5 Lint / 质量门禁 + +`pom.xml` 当前未配置 Checkstyle、Spotless、PMD 等专用 lint 插件。 +因此以“编译 + 测试”作为质量门禁: + +- 快速门禁:`mvn clean compile` +- 完整门禁:`mvn clean test` + +### 3.6 当前测试覆盖(供 Agent 参考) + +目前已存在以下单测类,可按需定向执行: + +- `ConnectionControllerTest` +- `FileControllerTest` +- `ConnectionServiceTest` +- `SessionManagerTest` +- `LocalFileServiceTest` + +示例: + +- 执行单个测试类:`mvn -Dtest=LocalFileServiceTest test` +- 执行单个测试方法:`mvn -Dtest=LocalFileServiceTest#renameFile_shouldAllowRenameInSameDirectory test` + +## 4)代码风格与工程约定 + +## 4.1 Java / Spring 通用约定 + +- 必须保持 Java 8 兼容,不使用 Java 9+ 语法。 +- 包路径维持在 `com.sftp.manager` 下。 +- 控制器保持轻量,核心逻辑放到 `service` 层。 +- API 返回统一使用 `ApiResponse`。 +- 延续既有接口前缀: + - `/api/connection/*` + - `/api/files/*` +- 沿用已有术语命名:`sessionId`、`sourcePath`、`targetPath`、`recursive`。 + +## 4.2 依赖注入风格 + +- 现有代码大量使用字段注入 `@Autowired`,修改旧文件时保持一致。 +- 新增类可用构造器注入,但同一类内不要混用多种注入风格。 +- 避免“仅改一点却全文件重构注入方式”的无关变更。 + +## 4.3 Imports 与格式 + +- 保持文件现有 import 分组习惯,尽量与周边代码一致。 +- 一般顺序:项目/第三方 → Spring → `javax.*` → `java.*`。 +- 避免通配符导入(`*`),优先显式导入。 +- 使用 4 空格缩进,不使用 Tab。 +- 保持现有大括号、注解换行和空行风格。 + +## 4.4 类型、DTO 与实体 + +- 避免原始类型集合,优先使用泛型(如 `List`)。 +- DTO/实体中,允许为空的数值或布尔优先用包装类型(`Integer`、`Long`、`Boolean`)。 +- DTO 只做传输,不承载业务逻辑。 +- 若同类文件已使用 Lombok `@Data`,新增同类对象可延续该模式。 + +## 4.5 命名规范 + +- 类名:PascalCase(如 `ConnectionService`)。 +- 方法/变量/参数:camelCase(如 `getFileInfo`)。 +- 常量:UPPER_SNAKE_CASE(如 `API_BASE`)。 +- REST 路径:小写,必要时使用连字符,优先复用已有路径。 +- Service 方法名优先“动词 + 对象”(如 `uploadToSftp`、`downloadFromSftp`)。 + +## 4.6 错误处理规范 + +- 先做参数校验,再执行核心逻辑。 +- 控制器边界统一捕获异常,并映射为 `ApiResponse.error(...)`。 +- 错误信息要包含上下文(路径、会话、操作类型),但不得泄露敏感信息。 +- 清理流程中的异常可按现有模式忽略,但主流程异常不可静默吞掉。 +- 保持面向用户的提示语风格与现有代码一致(当前以中文提示为主)。 + +## 4.7 文件与流处理 + +- 所有 `InputStream` / `OutputStream` 优先使用 `try-with-resources`。 +- 明确“由谁负责关闭流”,避免重复关闭或泄漏。 +- 递归文件操作需考虑部分失败、非法路径、权限不足等场景。 +- 跨平台路径处理继续使用现有模式(`File.separator`、路径规范化)。 +- 本地路径处理应统一规范化(`getCanonicalFile`),拒绝 `..` 上级目录穿越。 +- 删除操作应防护根目录与系统关键目录(Windows 与 Unix 路径均需考虑)。 +- 重命名默认按“同目录重命名”语义实现,不在重命名接口里隐式做跨目录移动。 + +## 4.8 持久化与配置约定 + +- 实体默认值与时间戳钩子(`@PrePersist` / `@PreUpdate`)保持一致。 +- 不提交 `data/` 下数据库产物(`.gitignore` 已忽略相关文件)。 +- 生产配置中的环境变量占位符应保留(如 `DB_USERNAME`、`DB_PASSWORD`)。 +- 非需求驱动情况下,不扩展 Actuator 暴露范围。 + +## 4.9 前端静态资源约定 + +- 项目未配置 Node 构建链路,前端资源直接编辑 `static` 目录。 +- 保持当前 jQuery 风格与状态驱动结构(见 `static/js/app.js`)。 +- 保持双面板交互和响应式行为,不破坏现有 UX。 +- 保持 CSS 变量与设计令牌风格,不做大规模 UI 体系替换。 + +## 5)Agent 工作流程建议 + +- 开始改动前,先阅读目标文件及相邻文件风格。 +- 以最小必要改动为原则,避免无关重构。 +- 未明确要求时,不更改端口、context-path、profile 行为。 +- 新增功能优先补充可验证逻辑;有测试目录后优先补单元测试。 +- 完成后至少运行一次编译校验;如有测试则执行相关测试。 +- 若新增关键命令或流程,请同步更新本 `AGENTS.md`。 +- 涉及安全边界(认证、文件系统、CORS、主机密钥)变更后,必须补至少一条对应单测。 + +## 6)常用命令速查 + +- `mvn clean compile` +- `mvn test` +- `mvn -Dtest=ClassName test` +- `mvn -Dtest=ClassName#methodName test` +- `mvn spring-boot:run` +- `mvn clean package -DskipTests` +- `java -jar target/sftp-manager-1.0.0.jar --spring.profiles.active=prod` +- `docker compose up -d --build` + +请随项目演进持续维护本文件,确保其内容始终与仓库实际配置一致。 diff --git a/src/main/java/com/sftp/manager/SftpManagerApplication.java b/src/main/java/com/sftp/manager/SftpManagerApplication.java index e399c30..d9cce3e 100644 --- a/src/main/java/com/sftp/manager/SftpManagerApplication.java +++ b/src/main/java/com/sftp/manager/SftpManagerApplication.java @@ -3,9 +3,11 @@ package com.sftp.manager; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaRepositories +@EnableScheduling public class SftpManagerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sftp/manager/config/WebConfig.java b/src/main/java/com/sftp/manager/config/WebConfig.java index ef92f23..abc74bf 100644 --- a/src/main/java/com/sftp/manager/config/WebConfig.java +++ b/src/main/java/com/sftp/manager/config/WebConfig.java @@ -1,5 +1,6 @@ package com.sftp.manager.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; @@ -9,10 +10,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { + @Value("${app.cors.allowed-origins:http://localhost:48081,http://127.0.0.1:48081}") + private String allowedOrigins; + @Override public void addCorsMappings(CorsRegistry registry) { + String[] origins = allowedOrigins.split(","); + for (int i = 0; i < origins.length; i++) { + origins[i] = origins[i].trim(); + } + registry.addMapping("/**") - .allowedOrigins("*") + .allowedOriginPatterns(origins) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .maxAge(3600); diff --git a/src/main/java/com/sftp/manager/controller/ConnectionController.java b/src/main/java/com/sftp/manager/controller/ConnectionController.java index 656d68e..77cc332 100644 --- a/src/main/java/com/sftp/manager/controller/ConnectionController.java +++ b/src/main/java/com/sftp/manager/controller/ConnectionController.java @@ -2,9 +2,12 @@ package com.sftp.manager.controller; import com.sftp.manager.dto.ApiResponse; import com.sftp.manager.dto.ConnectionRequest; +import com.sftp.manager.dto.DisconnectRequest; import com.sftp.manager.model.Connection; import com.sftp.manager.service.ConnectionService; import com.sftp.manager.service.SessionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @@ -13,9 +16,10 @@ import java.util.Map; @RestController @RequestMapping("/api/connection") -@CrossOrigin(origins = "*") public class ConnectionController { + private static final Logger log = LoggerFactory.getLogger(ConnectionController.class); + @Autowired private ConnectionService connectionService; @@ -23,44 +27,35 @@ public class ConnectionController { private SessionManager sessionManager; @PostMapping("/connect") - public ApiResponse connect(@RequestBody ConnectionRequest request) { - try { - String sessionId = connectionService.connect(request); - return ApiResponse.success("连接成功", sessionId); - } catch (Exception e) { - return ApiResponse.error("连接失败: " + e.getMessage()); - } + public ApiResponse connect(@RequestBody ConnectionRequest request) throws Exception { + String sessionId = connectionService.connect(request); + log.info("连接接口调用成功: sessionId={}", sessionId); + return ApiResponse.success("连接成功", sessionId); } @PostMapping("/disconnect") - public ApiResponse disconnect(@RequestBody Map request) { - try { - String sessionId = request.get("sessionId"); - connectionService.disconnect(sessionId); - return ApiResponse.success("断开成功", null); - } catch (Exception e) { - return ApiResponse.error("断开失败: " + e.getMessage()); + public ApiResponse disconnect(@RequestBody DisconnectRequest request) { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + + String sessionId = request.getSessionId().trim(); + connectionService.disconnect(sessionId); + log.info("断开连接接口调用成功: sessionId={}", sessionId); + return ApiResponse.success("断开成功", null); } @PostMapping("/save") public ApiResponse saveConnection(@RequestBody Connection connection) { - try { - Connection saved = connectionService.saveConnection(connection); - return ApiResponse.success("保存成功", saved); - } catch (Exception e) { - return ApiResponse.error("保存失败: " + e.getMessage()); - } + Connection saved = connectionService.saveConnection(connection); + log.info("保存连接成功: id={}, name={}", saved.getId(), saved.getName()); + return ApiResponse.success("保存成功", saved); } @GetMapping("/list") public ApiResponse> listConnections() { - try { - List connections = connectionService.listConnections(); - return ApiResponse.success("查询成功", connections); - } catch (Exception e) { - return ApiResponse.error("查询失败: " + e.getMessage()); - } + List connections = connectionService.listConnections(); + return ApiResponse.success("查询成功", connections); } @GetMapping("/{id}") @@ -79,20 +74,13 @@ public class ConnectionController { @DeleteMapping("/{id}") public ApiResponse deleteConnection(@PathVariable Long id) { - try { - connectionService.deleteConnection(id); - return ApiResponse.success("删除成功", null); - } catch (Exception e) { - return ApiResponse.error("删除失败: " + e.getMessage()); - } + connectionService.deleteConnection(id); + log.info("删除连接成功: id={}", id); + return ApiResponse.success("删除成功", null); } @GetMapping("/active") public ApiResponse> getActiveConnections() { - try { - return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections()); - } catch (Exception e) { - return ApiResponse.error("查询失败: " + e.getMessage()); - } + return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections()); } } diff --git a/src/main/java/com/sftp/manager/controller/FileController.java b/src/main/java/com/sftp/manager/controller/FileController.java index 9abb4bc..87149b6 100644 --- a/src/main/java/com/sftp/manager/controller/FileController.java +++ b/src/main/java/com/sftp/manager/controller/FileController.java @@ -2,14 +2,19 @@ package com.sftp.manager.controller; import com.jcraft.jsch.ChannelSftp; import com.sftp.manager.dto.ApiResponse; +import com.sftp.manager.dto.BatchDeleteRequest; import com.sftp.manager.dto.BatchDeleteResult; +import com.sftp.manager.dto.DirectoryRequest; import com.sftp.manager.dto.FileListRequest; import com.sftp.manager.dto.FileOperationRequest; +import com.sftp.manager.dto.RenameRequest; import com.sftp.manager.dto.TransferRequest; import com.sftp.manager.model.FileInfo; import com.sftp.manager.service.LocalFileService; import com.sftp.manager.service.SessionManager; import com.sftp.manager.service.SftpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -21,6 +26,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URLEncoder; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,9 +34,10 @@ import java.util.UUID; @RestController @RequestMapping("/api/files") -@CrossOrigin(origins = "*") public class FileController { + private static final Logger log = LoggerFactory.getLogger(FileController.class); + @Autowired private LocalFileService localFileService; @@ -41,99 +48,126 @@ public class FileController { private SessionManager sessionManager; @PostMapping("/list") - public ApiResponse> listFiles(@RequestBody FileListRequest request) { - try { - String sessionId = request.getSessionId(); - String path = request.getPath(); - boolean showHidden = Boolean.TRUE.equals(request.getShowHidden()); - - List files; - if ("local".equals(sessionId)) { - files = localFileService.listFiles(path, showHidden); - } else { - files = sftpService.listFiles(sessionId, path, showHidden); - } - - return ApiResponse.success("查询成功", files); - } catch (Exception e) { - // SftpService 已抛出带「列出文件失败:」前缀的消息,此处不再重复拼接 - String msg = e.getMessage(); - return ApiResponse.error(msg != null ? msg : "列出文件失败"); + public ApiResponse> listFiles(@RequestBody FileListRequest request) throws Exception { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + + String sessionId = request.getSessionId().trim(); + String path = request.getPath(); + boolean showHidden = Boolean.TRUE.equals(request.getShowHidden()); + + List files; + if ("local".equals(sessionId)) { + files = localFileService.listFiles(path, showHidden); + } else { + files = sftpService.listFiles(sessionId, path, showHidden); + } + + log.debug("查询文件列表成功: sessionId={}, path={}, count={}", sessionId, path, files != null ? files.size() : 0); + + return ApiResponse.success("查询成功", files); } @PostMapping("/info") - public ApiResponse getFileInfo(@RequestBody FileOperationRequest request) { - try { - String sessionId = request.getSessionId(); - String path = request.getPath(); - - FileInfo fileInfo; - if ("local".equals(sessionId)) { - fileInfo = localFileService.getFileInfo(path); - } else { - fileInfo = sftpService.getFileInfo(sessionId, path); - } - - return ApiResponse.success("查询成功", fileInfo); - } catch (Exception e) { - return ApiResponse.error("获取文件信息失败: " + e.getMessage()); + public ApiResponse getFileInfo(@RequestBody FileOperationRequest request) throws Exception { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + + String sessionId = request.getSessionId().trim(); + String path = request.getPath(); + + FileInfo fileInfo; + if ("local".equals(sessionId)) { + fileInfo = localFileService.getFileInfo(path); + } else { + fileInfo = sftpService.getFileInfo(sessionId, path); + } + + return ApiResponse.success("查询成功", fileInfo); } @GetMapping("/path") - public ApiResponse> getCurrentPath(@RequestParam String sessionId) { - try { - Map result = new HashMap<>(); - if ("local".equals(sessionId)) { - result.put("path", System.getProperty("user.home")); - } else { - String path = sftpService.pwd(sessionId); - result.put("path", path); - } - return ApiResponse.success("查询成功", result); - } catch (Exception e) { - return ApiResponse.error("获取路径失败: " + e.getMessage()); + public ApiResponse> getCurrentPath(@RequestParam String sessionId) throws Exception { + if (sessionId == null || sessionId.trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + + String effectiveSessionId = sessionId.trim(); + Map result = new HashMap<>(); + if ("local".equals(effectiveSessionId)) { + result.put("path", System.getProperty("user.home")); + } else { + String path = sftpService.pwd(effectiveSessionId); + result.put("path", path); + } + return ApiResponse.success("查询成功", result); } // 上传文件 @PostMapping("/upload") public ApiResponse uploadFile(@RequestParam("file") MultipartFile file, - @RequestParam("targetSessionId") String targetSessionId, - @RequestParam("targetPath") String targetPath) { - try { - if ("local".equals(targetSessionId)) { - // 上传到本地 - File destFile = new File(targetPath, file.getOriginalFilename()); - file.transferTo(destFile); - } else { - // 上传到SFTP - try (InputStream inputStream = file.getInputStream()) { - String remotePath = targetPath.endsWith("/") ? - targetPath + file.getOriginalFilename() : - targetPath + "/" + file.getOriginalFilename(); - sftpService.uploadFile(targetSessionId, inputStream, remotePath, file.getSize()); - } - } - return ApiResponse.success("上传成功", null); - } catch (Exception e) { - return ApiResponse.error("上传失败: " + e.getMessage()); + @RequestParam("targetSessionId") String targetSessionId, + @RequestParam("targetPath") String targetPath) throws Exception { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("上传文件不能为空"); } + if (targetSessionId == null || targetSessionId.trim().isEmpty()) { + throw new IllegalArgumentException("目标会话ID不能为空"); + } + if (targetPath == null || targetPath.trim().isEmpty()) { + throw new IllegalArgumentException("目标路径不能为空"); + } + + String effectiveTargetSessionId = targetSessionId.trim(); + String effectiveTargetPath = targetPath.trim(); + + if ("local".equals(effectiveTargetSessionId)) { + // 上传到本地 + File destFile = new File(effectiveTargetPath, file.getOriginalFilename()); + file.transferTo(destFile); + log.info("上传到本地成功: targetPath={}, filename={}, size={}", effectiveTargetPath, file.getOriginalFilename(), file.getSize()); + } else { + // 上传到SFTP + try (InputStream inputStream = file.getInputStream()) { + String remotePath = effectiveTargetPath.endsWith("/") ? + effectiveTargetPath + file.getOriginalFilename() : + effectiveTargetPath + "/" + file.getOriginalFilename(); + sftpService.uploadFile(effectiveTargetSessionId, inputStream, remotePath, file.getSize()); + log.info("上传到SFTP成功: targetSessionId={}, remotePath={}, size={}", effectiveTargetSessionId, remotePath, file.getSize()); + } + } + return ApiResponse.success("上传成功", null); } // 下载文件 @GetMapping("/download") public void downloadFile(@RequestParam String sessionId, - @RequestParam String path, - HttpServletResponse response) { + @RequestParam String path, + HttpServletResponse response) { + if (sessionId == null || sessionId.trim().isEmpty()) { + writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, "会话ID不能为空"); + return; + } + if (path == null || path.trim().isEmpty()) { + writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, "文件路径不能为空"); + return; + } + + String effectiveSessionId = sessionId.trim(); + String effectivePath = path.trim(); + try { - if ("local".equals(sessionId)) { + if ("local".equals(effectiveSessionId)) { // 下载本地文件 - File file = new File(path); + File file = new File(effectivePath); + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("文件不存在: " + effectivePath); + } response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", - "attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8")); + "attachment; filename=" + encodeFileName(file.getName())); response.setContentLengthLong(file.length()); try (InputStream inputStream = new FileInputStream(file); @@ -145,42 +179,79 @@ public class FileController { } outputStream.flush(); } + log.info("本地文件下载成功: path={}", effectivePath); } else { // 下载SFTP文件 - ChannelSftp channel = sessionManager.getSession(sessionId); + ChannelSftp channel = sessionManager.getSession(effectiveSessionId); if (channel == null) { - throw new Exception("会话不存在或已断开"); + throw new IllegalArgumentException("会话不存在或已断开"); } - FileInfo fileInfo = sftpService.getFileInfo(sessionId, path); + FileInfo fileInfo = sftpService.getFileInfo(effectiveSessionId, effectivePath); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", - "attachment; filename=" + URLEncoder.encode(fileInfo.getName(), "UTF-8")); + "attachment; filename=" + encodeFileName(fileInfo.getName())); response.setContentLengthLong(fileInfo.getSize()); try (OutputStream outputStream = response.getOutputStream()) { - sftpService.downloadFile(sessionId, path, outputStream); + sftpService.downloadFile(effectiveSessionId, effectivePath, outputStream); outputStream.flush(); } + log.info("SFTP文件下载成功: sessionId={}, path={}", effectiveSessionId, effectivePath); } + } catch (IllegalArgumentException e) { + log.warn("下载请求参数错误: sessionId={}, path={}, error={}", effectiveSessionId, effectivePath, e.getMessage()); + writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); } catch (Exception e) { - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - try { - response.getWriter().write("下载失败: " + e.getMessage()); - } catch (Exception ex) { - // 忽略 - } + String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; + log.warn("下载失败: sessionId={}, path={}, error={}", effectiveSessionId, effectivePath, msg); + writeErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "下载失败: " + msg); + } + } + + private String encodeFileName(String fileName) throws Exception { + return URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + } + + private void writeErrorResponse(HttpServletResponse response, int status, String message) { + if (response == null || response.isCommitted()) { + return; + } + + response.setStatus(status); + response.setCharacterEncoding("UTF-8"); + response.setContentType("text/plain;charset=UTF-8"); + try { + response.getWriter().write(message); + } catch (Exception ignored) { + // 忽略 } } // 服务器间传输 @PostMapping("/transfer") - public ApiResponse transferFiles(@RequestBody TransferRequest request) { + public ApiResponse transferFiles(@RequestBody TransferRequest request) throws Exception { String sourceSessionId = request.getSourceSessionId(); String targetSessionId = request.getTargetSessionId(); String targetPath = request.getTargetPath(); + if (sourceSessionId == null || sourceSessionId.trim().isEmpty()) { + throw new IllegalArgumentException("源会话ID不能为空"); + } + if (targetSessionId == null || targetSessionId.trim().isEmpty()) { + throw new IllegalArgumentException("目标会话ID不能为空"); + } + if (targetPath == null || targetPath.trim().isEmpty()) { + throw new IllegalArgumentException("目标路径不能为空"); + } + + sourceSessionId = sourceSessionId.trim(); + targetSessionId = targetSessionId.trim(); + targetPath = targetPath.trim(); + + log.info("开始文件传输: sourceSessionId={}, targetSessionId={}, targetPath={}", sourceSessionId, targetSessionId, targetPath); + String prefix; if ("local".equals(sourceSessionId) && !"local".equals(targetSessionId)) { prefix = "上传到服务器失败"; @@ -192,127 +263,123 @@ public class FileController { prefix = "传输失败"; } - try { + List sourcePaths = request.getSourcePaths(); + if (sourcePaths == null || sourcePaths.isEmpty()) { + // 兼容旧字段:仅当 sourcePaths 为空时才使用单个 sourcePath + if (request.getSourcePath() == null || request.getSourcePath().isEmpty()) { + throw new IllegalArgumentException("源路径不能为空"); + } + sourcePaths = java.util.Collections.singletonList(request.getSourcePath()); + } - List sourcePaths = request.getSourcePaths(); - if (sourcePaths == null || sourcePaths.isEmpty()) { - // 兼容旧字段:仅当 sourcePaths 为空时才使用单个 sourcePath - if (request.getSourcePath() == null || request.getSourcePath().isEmpty()) { - return ApiResponse.error("源路径不能为空"); - } - sourcePaths = java.util.Collections.singletonList(request.getSourcePath()); + boolean recursive = Boolean.TRUE.equals(request.getRecursive()); + + int successCount = 0; + int failCount = 0; + StringBuilder failMessages = new StringBuilder(); + + for (String sourcePath : sourcePaths) { + if (sourcePath == null || sourcePath.isEmpty()) { + failCount++; + failMessages.append("源路径为空; "); + continue; } - boolean recursive = Boolean.TRUE.equals(request.getRecursive()); - - if (targetPath == null || targetPath.isEmpty()) { - return ApiResponse.error("目标路径不能为空"); - } - - int successCount = 0; - int failCount = 0; - StringBuilder failMessages = new StringBuilder(); - - for (String sourcePath : sourcePaths) { - if (sourcePath == null || sourcePath.isEmpty()) { + boolean isDirectory; + String fileName; + if ("local".equals(sourceSessionId)) { + File file = new File(sourcePath); + if (!file.exists()) { failCount++; - failMessages.append("源路径为空; "); + failMessages.append(sourcePath).append(" 不存在; "); continue; } + isDirectory = file.isDirectory(); + fileName = file.getName(); + } else { + FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); + isDirectory = fileInfo.isDirectory(); + fileName = fileInfo.getName(); + } - boolean isDirectory; - String fileName; - if ("local".equals(sourceSessionId)) { - File file = new File(sourcePath); - if (!file.exists()) { - failCount++; - failMessages.append(sourcePath).append(" 不存在; "); - continue; + // 构建目标路径(目标目录下的文件/目录名称与源名称保持一致) + String finalTargetPath = targetPath.endsWith("/") ? + targetPath + fileName : + targetPath + "/" + fileName; + + try { + if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) { + // 本地到本地 + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + copyLocalDirectory(sourcePath, finalTargetPath); + } else { + Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } else if ("local".equals(sourceSessionId)) { + // 本地到SFTP(上传到服务器) + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + localFileService.uploadDirectoryToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); + } else { + localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); + } + } else if ("local".equals(targetSessionId)) { + // SFTP到本地(从服务器下载) + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); + } else { + localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); } - isDirectory = file.isDirectory(); - fileName = file.getName(); } else { - FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); - isDirectory = fileInfo.isDirectory(); - fileName = fileInfo.getName(); - } - - // 构建目标路径(目标目录下的文件/目录名称与源名称保持一致) - String finalTargetPath = targetPath.endsWith("/") ? - targetPath + fileName : - targetPath + "/" + fileName; - - try { - if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) { - // 本地到本地 - if (isDirectory) { - if (!recursive) { - throw new Exception("目录传输需要开启递归(recursive=true)"); - } - copyLocalDirectory(sourcePath, finalTargetPath); - } else { - Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath()); + // SFTP到SFTP + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); } - } else if ("local".equals(sourceSessionId)) { - // 本地到SFTP(上传到服务器) - if (isDirectory) { - if (!recursive) { - throw new Exception("目录传输需要开启递归(recursive=true)"); - } - localFileService.uploadDirectoryToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); - } else { - localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); - } - } else if ("local".equals(targetSessionId)) { - // SFTP到本地(从服务器下载) - if (isDirectory) { - if (!recursive) { - throw new Exception("目录传输需要开启递归(recursive=true)"); - } - localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); - } else { - localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); + // 通过本地临时目录实现 SFTP 之间的目录传输 + String tempRoot = System.getProperty("java.io.tmpdir") + + File.separator + "sftp-manager-" + UUID.randomUUID(); + File tempDir = new File(tempRoot); + try { + localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, tempRoot, sftpService); + localFileService.uploadDirectoryToSftp(tempRoot, targetSessionId, finalTargetPath, sftpService); + } finally { + deleteLocalDirectory(tempDir); } } else { - // SFTP到SFTP - if (isDirectory) { - if (!recursive) { - throw new Exception("目录传输需要开启递归(recursive=true)"); - } - // 通过本地临时目录实现 SFTP 之间的目录传输 - String tempRoot = System.getProperty("java.io.tmpdir") + - File.separator + "sftp-manager-" + UUID.randomUUID(); - File tempDir = new File(tempRoot); - try { - localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, tempRoot, sftpService); - localFileService.uploadDirectoryToSftp(tempRoot, targetSessionId, finalTargetPath, sftpService); - } finally { - deleteLocalDirectory(tempDir); - } - } else { - sftpService.transferBetweenSftp(sourceSessionId, sourcePath, - targetSessionId, finalTargetPath); - } + sftpService.transferBetweenSftp(sourceSessionId, sourcePath, + targetSessionId, finalTargetPath); } - successCount++; - } catch (Exception e) { - failCount++; - String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; - failMessages.append(sourcePath).append(" - ").append(msg).append("; "); } + successCount++; + } catch (Exception e) { + failCount++; + String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; + failMessages.append(sourcePath).append(" - ").append(msg).append("; "); } + } - if (failCount == 0) { - return ApiResponse.success("传输成功", null); - } else if (successCount == 0) { - String msg = failMessages.length() > 0 ? failMessages.toString() : "全部传输失败"; - return ApiResponse.error(prefix + ": " + msg); - } else { - String msg = "部分文件传输失败,成功 " + successCount + " 个,失败 " + failCount + " 个。详情: " + failMessages; - return ApiResponse.error(prefix + ": " + msg); - } - } catch (Exception e) { - String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; + if (failCount == 0) { + log.info("文件传输完成: 全部成功, sourceSessionId={}, targetSessionId={}, total={}", + sourceSessionId, targetSessionId, successCount); + return ApiResponse.success("传输成功", null); + } else if (successCount == 0) { + String msg = failMessages.length() > 0 ? failMessages.toString() : "全部传输失败"; + log.warn("文件传输完成: 全部失败, sourceSessionId={}, targetSessionId={}, failCount={}, detail={}", + sourceSessionId, targetSessionId, failCount, msg); + return ApiResponse.error(prefix + ": " + msg); + } else { + String msg = "部分文件传输失败,成功 " + successCount + " 个,失败 " + failCount + " 个。详情: " + failMessages; + log.warn("文件传输完成: 部分失败, sourceSessionId={}, targetSessionId={}, successCount={}, failCount={}, detail={}", + sourceSessionId, targetSessionId, successCount, failCount, failMessages.toString()); return ApiResponse.error(prefix + ": " + msg); } } @@ -341,7 +408,7 @@ public class FileController { if (file.isDirectory()) { copyLocalDirectory(file.getAbsolutePath(), targetChild.getAbsolutePath()); } else { - Files.copy(file.toPath(), targetChild.toPath()); + Files.copy(file.toPath(), targetChild.toPath(), StandardCopyOption.REPLACE_EXISTING); } } } @@ -375,107 +442,108 @@ public class FileController { // 删除单个文件 @DeleteMapping("/delete") public ApiResponse deleteFile(@RequestParam String sessionId, - @RequestParam String path) { - try { - if ("local".equals(sessionId)) { - localFileService.deleteFile(path); - } else { - sftpService.deleteFile(sessionId, path); - } - return ApiResponse.success("删除成功", null); - } catch (Exception e) { - return ApiResponse.error("删除失败: " + e.getMessage()); + @RequestParam String path) throws Exception { + if (sessionId == null || sessionId.trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + if (path == null || path.trim().isEmpty()) { + throw new IllegalArgumentException("路径不能为空"); + } + + String effectiveSessionId = sessionId.trim(); + String effectivePath = path.trim(); + if ("local".equals(effectiveSessionId)) { + localFileService.deleteFile(effectivePath); + } else { + sftpService.deleteFile(effectiveSessionId, effectivePath); + } + log.info("删除成功: sessionId={}, path={}", effectiveSessionId, effectivePath); + return ApiResponse.success("删除成功", null); } // 创建目录(单级) @PostMapping("/mkdir") - public ApiResponse createDirectory(@RequestBody Map request) { - try { - String sessionId = request.get("sessionId"); - String path = request.get("path"); - - if (path == null || path.isEmpty()) { - return ApiResponse.error("路径不能为空"); - } - - boolean result; - if ("local".equals(sessionId)) { - result = localFileService.createDirectory(path); - } else { - result = sftpService.createDirectory(sessionId, path); - } - - if (result) { - return ApiResponse.success("创建成功", null); - } else { - return ApiResponse.error("创建失败"); - } - } catch (Exception e) { - return ApiResponse.error("创建失败: " + e.getMessage()); + public ApiResponse createDirectory(@RequestBody DirectoryRequest request) throws Exception { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + if (request.getPath() == null || request.getPath().trim().isEmpty()) { + throw new IllegalArgumentException("路径不能为空"); + } + + String sessionId = request.getSessionId().trim(); + String path = request.getPath().trim(); + + if ("local".equals(sessionId)) { + localFileService.createDirectory(path); + } else { + sftpService.createDirectory(sessionId, path); + } + + log.info("创建目录成功: sessionId={}, path={}", sessionId, path); + + return ApiResponse.success("创建成功", null); } // 创建多级目录 @PostMapping("/mkdir-p") - public ApiResponse createDirectories(@RequestBody Map request) { - try { - String sessionId = request.get("sessionId"); - String path = request.get("path"); - - if (path == null || path.isEmpty()) { - return ApiResponse.error("路径不能为空"); - } - - boolean result; - if ("local".equals(sessionId)) { - result = localFileService.createDirectories(path); - } else { - result = sftpService.createDirectories(sessionId, path); - } - - if (result) { - return ApiResponse.success("创建成功", null); - } else { - return ApiResponse.error("创建失败"); - } - } catch (Exception e) { - return ApiResponse.error("创建失败: " + e.getMessage()); + public ApiResponse createDirectories(@RequestBody DirectoryRequest request) throws Exception { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + if (request.getPath() == null || request.getPath().trim().isEmpty()) { + throw new IllegalArgumentException("路径不能为空"); + } + + String sessionId = request.getSessionId().trim(); + String path = request.getPath().trim(); + + if ("local".equals(sessionId)) { + localFileService.createDirectories(path); + } else { + sftpService.createDirectories(sessionId, path); + } + + log.info("创建多级目录成功: sessionId={}, path={}", sessionId, path); + + return ApiResponse.success("创建成功", null); } // 重命名文件 @PostMapping("/rename") - public ApiResponse renameFile(@RequestBody Map request) { - try { - String sessionId = request.get("sessionId"); - String oldPath = request.get("oldPath"); - String newName = request.get("newName"); - - if (newName == null || newName.trim().isEmpty()) { - return ApiResponse.error("新文件名不能为空"); - } - newName = newName.trim(); - - String newPath; - if ("local".equals(sessionId)) { - File oldFile = new File(oldPath); - File parentDir = oldFile.getParentFile(); - if (parentDir == null) { - return ApiResponse.error("无法获取父目录"); - } - newPath = parentDir.getPath() + File.separator + newName; - localFileService.renameFile(oldPath, newPath); - } else { - String parentPath = getParentPathFromString(oldPath); - newPath = parentPath.endsWith("/") ? parentPath + newName : parentPath + "/" + newName; - sftpService.renameFile(sessionId, oldPath, newPath); - } - - return ApiResponse.success("重命名成功", null); - } catch (Exception e) { - return ApiResponse.error("重命名失败: " + e.getMessage()); + public ApiResponse renameFile(@RequestBody RenameRequest request) throws Exception { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + if (request.getOldPath() == null || request.getOldPath().trim().isEmpty()) { + throw new IllegalArgumentException("原路径不能为空"); + } + if (request.getNewName() == null || request.getNewName().trim().isEmpty()) { + throw new IllegalArgumentException("新文件名不能为空"); + } + + String sessionId = request.getSessionId().trim(); + String oldPath = request.getOldPath().trim(); + String newName = request.getNewName().trim(); + + String newPath; + if ("local".equals(sessionId)) { + File oldFile = new File(oldPath); + File parentDir = oldFile.getParentFile(); + if (parentDir == null) { + throw new IllegalArgumentException("无法获取父目录"); + } + newPath = parentDir.getPath() + File.separator + newName; + localFileService.renameFile(oldPath, newPath); + } else { + String parentPath = getParentPathFromString(oldPath); + newPath = parentPath.endsWith("/") ? parentPath + newName : parentPath + "/" + newName; + sftpService.renameFile(sessionId, oldPath, newPath); + } + + log.info("重命名成功: sessionId={}, oldPath={}, newPath={}", sessionId, oldPath, newPath); + + return ApiResponse.success("重命名成功", null); } // 从路径字符串获取父路径(用于 SFTP 路径) @@ -493,25 +561,27 @@ public class FileController { // 批量删除 @PostMapping("/batch-delete") - public ApiResponse batchDelete(@RequestBody Map request) { - try { - String sessionId = (String) request.get("sessionId"); - @SuppressWarnings("unchecked") - List paths = (List) request.get("paths"); - if (paths == null) { - paths = new java.util.ArrayList<>(); - } - - BatchDeleteResult result; - if ("local".equals(sessionId)) { - result = localFileService.batchDelete(paths); - } else { - result = sftpService.batchDelete(sessionId, paths); - } - - return ApiResponse.success("删除完成", result); - } catch (Exception e) { - return ApiResponse.error("批量删除失败: " + e.getMessage()); + public ApiResponse batchDelete(@RequestBody BatchDeleteRequest request) { + if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); } + + String sessionId = request.getSessionId().trim(); + List paths = request.getPaths(); + if (paths == null) { + paths = new java.util.ArrayList<>(); + } + + BatchDeleteResult result; + if ("local".equals(sessionId)) { + result = localFileService.batchDelete(paths); + } else { + result = sftpService.batchDelete(sessionId, paths); + } + + log.info("批量删除完成: sessionId={}, total={}, successCount={}, failCount={}", + sessionId, paths.size(), result.getSuccessCount(), result.getFailCount()); + + return ApiResponse.success("删除完成", result); } } diff --git a/src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java b/src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java new file mode 100644 index 0000000..05990e4 --- /dev/null +++ b/src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java @@ -0,0 +1,11 @@ +package com.sftp.manager.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class BatchDeleteRequest { + private String sessionId; + private List paths; +} diff --git a/src/main/java/com/sftp/manager/dto/DirectoryRequest.java b/src/main/java/com/sftp/manager/dto/DirectoryRequest.java new file mode 100644 index 0000000..4e74d38 --- /dev/null +++ b/src/main/java/com/sftp/manager/dto/DirectoryRequest.java @@ -0,0 +1,9 @@ +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class DirectoryRequest { + private String sessionId; + private String path; +} diff --git a/src/main/java/com/sftp/manager/dto/DisconnectRequest.java b/src/main/java/com/sftp/manager/dto/DisconnectRequest.java new file mode 100644 index 0000000..820d3ed --- /dev/null +++ b/src/main/java/com/sftp/manager/dto/DisconnectRequest.java @@ -0,0 +1,8 @@ +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class DisconnectRequest { + private String sessionId; +} diff --git a/src/main/java/com/sftp/manager/dto/RenameRequest.java b/src/main/java/com/sftp/manager/dto/RenameRequest.java new file mode 100644 index 0000000..48aecfe --- /dev/null +++ b/src/main/java/com/sftp/manager/dto/RenameRequest.java @@ -0,0 +1,10 @@ +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class RenameRequest { + private String sessionId; + private String oldPath; + private String newName; +} diff --git a/src/main/java/com/sftp/manager/exception/GlobalExceptionHandler.java b/src/main/java/com/sftp/manager/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7351a28 --- /dev/null +++ b/src/main/java/com/sftp/manager/exception/GlobalExceptionHandler.java @@ -0,0 +1,41 @@ +package com.sftp.manager.exception; + +import com.sftp.manager.dto.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + String message = e.getMessage() == null || e.getMessage().isEmpty() ? "参数错误" : e.getMessage(); + log.warn("请求参数错误: {}", message); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(message)); + } + + @ExceptionHandler(org.springframework.web.multipart.MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSizeExceededException() { + log.warn("上传文件超过大小限制"); + return ResponseEntity + .status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(ApiResponse.error("上传文件超过大小限制")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + String message = e.getMessage() == null || e.getMessage().isEmpty() ? "系统错误" : e.getMessage(); + log.error("系统异常: {}", message, e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("系统异常: " + message)); + } +} diff --git a/src/main/java/com/sftp/manager/model/Connection.java b/src/main/java/com/sftp/manager/model/Connection.java index bc61762..e9a30c3 100644 --- a/src/main/java/com/sftp/manager/model/Connection.java +++ b/src/main/java/com/sftp/manager/model/Connection.java @@ -1,5 +1,6 @@ package com.sftp.manager.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; @@ -24,10 +25,12 @@ public class Connection { private String username; // 用户名 @Column(columnDefinition = "TEXT") + @JsonIgnore private String password; // 密码(加密存储) private String privateKeyPath; // 私钥路径(可选) + @JsonIgnore private String passPhrase; // 私钥密码(可选) private Integer connectTimeout; // 连接超时时间 diff --git a/src/main/java/com/sftp/manager/service/ConnectionService.java b/src/main/java/com/sftp/manager/service/ConnectionService.java index 0c718a5..ea2b283 100644 --- a/src/main/java/com/sftp/manager/service/ConnectionService.java +++ b/src/main/java/com/sftp/manager/service/ConnectionService.java @@ -6,16 +6,21 @@ import com.jcraft.jsch.Session; import com.sftp.manager.dto.ConnectionRequest; import com.sftp.manager.model.Connection; import com.sftp.manager.repository.ConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.io.File; import java.util.List; import java.util.UUID; @Service public class ConnectionService { + private static final Logger log = LoggerFactory.getLogger(ConnectionService.class); + @Autowired private ConnectionRepository connectionRepository; @@ -28,7 +33,20 @@ public class ConnectionService { @Value("${app.sftp.max-retries:3}") private int maxRetries; + @Value("${app.sftp.strict-host-key-checking:false}") + private boolean strictHostKeyChecking; + + @Value("${app.sftp.known-hosts-path:}") + private String knownHostsPath; + public String connect(ConnectionRequest request) throws Exception { + ConnectionRequest effectiveRequest = buildEffectiveRequest(request); + + String host = effectiveRequest.getHost(); + Integer port = effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22; + String username = effectiveRequest.getUsername(); + log.info("开始建立SFTP连接: host={}, port={}, username={}", host, port, username); + JSch jsch = new JSch(); Session session = null; Channel channel = null; @@ -39,25 +57,33 @@ public class ConnectionService { while (retryCount < maxRetries) { try { + log.debug("连接尝试: host={}, port={}, username={}, attempt={}/{}", + host, port, username, retryCount + 1, maxRetries); + // 配置私钥(如果提供) - if (request.getPrivateKeyPath() != null && !request.getPrivateKeyPath().isEmpty()) { - jsch.addIdentity(request.getPrivateKeyPath(), - request.getPassPhrase() != null ? request.getPassPhrase() : ""); + if (effectiveRequest.getPrivateKeyPath() != null && !effectiveRequest.getPrivateKeyPath().isEmpty()) { + jsch.addIdentity(effectiveRequest.getPrivateKeyPath(), + effectiveRequest.getPassPhrase() != null ? effectiveRequest.getPassPhrase() : ""); } // 创建会话 - session = jsch.getSession(request.getUsername(), - request.getHost(), - request.getPort() != null ? request.getPort() : 22); + session = jsch.getSession(effectiveRequest.getUsername(), + effectiveRequest.getHost(), + effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22); // 配置密码(如果使用密码认证) - if (request.getPassword() != null && !request.getPassword().isEmpty()) { - session.setPassword(request.getPassword()); + if (effectiveRequest.getPassword() != null && !effectiveRequest.getPassword().isEmpty()) { + session.setPassword(effectiveRequest.getPassword()); } - // 跳过主机密钥验证 + // 主机密钥验证 java.util.Properties config = new java.util.Properties(); - config.put("StrictHostKeyChecking", "no"); + if (strictHostKeyChecking) { + configureKnownHosts(jsch); + config.put("StrictHostKeyChecking", "yes"); + } else { + config.put("StrictHostKeyChecking", "no"); + } session.setConfig(config); // 设置超时 @@ -70,9 +96,9 @@ public class ConnectionService { sftpChannel = (com.jcraft.jsch.ChannelSftp) channel; // 如果指定了默认路径,切换到该路径 - if (request.getRootPath() != null && !request.getRootPath().isEmpty()) { + if (effectiveRequest.getRootPath() != null && !effectiveRequest.getRootPath().isEmpty()) { try { - sftpChannel.cd(request.getRootPath()); + sftpChannel.cd(effectiveRequest.getRootPath()); } catch (Exception e) { // 路径不存在,使用默认路径 } @@ -80,22 +106,28 @@ public class ConnectionService { // 创建连接对象(用于保存配置) Connection connection = new Connection(); - connection.setName(request.getName()); - connection.setHost(request.getHost()); - connection.setPort(request.getPort() != null ? request.getPort() : 22); - connection.setUsername(request.getUsername()); - connection.setPassword(request.getPassword()); - connection.setPrivateKeyPath(request.getPrivateKeyPath()); - connection.setPassPhrase(request.getPassPhrase()); - connection.setRootPath(request.getRootPath()); + connection.setId(effectiveRequest.getId()); + connection.setName(effectiveRequest.getName()); + connection.setHost(effectiveRequest.getHost()); + connection.setPort(effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22); + connection.setUsername(effectiveRequest.getUsername()); + connection.setPassword(effectiveRequest.getPassword()); + connection.setPrivateKeyPath(effectiveRequest.getPrivateKeyPath()); + connection.setPassPhrase(effectiveRequest.getPassPhrase()); + connection.setRootPath(effectiveRequest.getRootPath()); connection.setConnectTimeout(connectionTimeout); // 添加到会话管理器 - return sessionManager.addSession(sftpChannel, connection); + String sessionId = sessionManager.addSession(sftpChannel, connection); + log.info("SFTP连接成功: host={}, port={}, username={}, sessionId={}", + host, port, username, sessionId); + return sessionId; } catch (Exception e) { lastException = e; retryCount++; + log.warn("SFTP连接失败: host={}, port={}, username={}, attempt={}/{}, error={}", + host, port, username, retryCount, maxRetries, e.getMessage()); // 清理资源 if (channel != null && channel.isConnected()) { @@ -114,6 +146,8 @@ public class ConnectionService { } if (retryCount >= maxRetries) { + log.error("SFTP连接最终失败: host={}, port={}, username={}, error={}", + host, port, username, e.getMessage()); throw new Exception("连接失败: " + e.getMessage(), e); } @@ -134,7 +168,99 @@ public class ConnectionService { throw new Exception("连接失败", lastException); } + private ConnectionRequest buildEffectiveRequest(ConnectionRequest request) throws Exception { + if (request == null) { + throw new Exception("连接参数不能为空"); + } + + ConnectionRequest effective = new ConnectionRequest(); + effective.setId(request.getId()); + + if (request.getId() != null) { + Connection saved = connectionRepository.findById(request.getId()).orElse(null); + if (saved == null) { + throw new Exception("连接不存在"); + } + + effective.setName(saved.getName()); + effective.setHost(saved.getHost()); + effective.setPort(saved.getPort()); + effective.setUsername(saved.getUsername()); + effective.setPassword(saved.getPassword()); + effective.setPrivateKeyPath(saved.getPrivateKeyPath()); + effective.setPassPhrase(saved.getPassPhrase()); + effective.setRootPath(saved.getRootPath()); + + // 允许调用方覆盖部分字段 + if (request.getHost() != null && !request.getHost().isEmpty()) { + effective.setHost(request.getHost()); + } + if (request.getPort() != null) { + effective.setPort(request.getPort()); + } + if (request.getUsername() != null && !request.getUsername().isEmpty()) { + effective.setUsername(request.getUsername()); + } + if (request.getPassword() != null && !request.getPassword().isEmpty()) { + effective.setPassword(request.getPassword()); + } + if (request.getPrivateKeyPath() != null && !request.getPrivateKeyPath().isEmpty()) { + effective.setPrivateKeyPath(request.getPrivateKeyPath()); + } + if (request.getPassPhrase() != null && !request.getPassPhrase().isEmpty()) { + effective.setPassPhrase(request.getPassPhrase()); + } + if (request.getRootPath() != null && !request.getRootPath().isEmpty()) { + effective.setRootPath(request.getRootPath()); + } + } else { + effective.setName(request.getName()); + effective.setHost(request.getHost()); + effective.setPort(request.getPort()); + effective.setUsername(request.getUsername()); + effective.setPassword(request.getPassword()); + effective.setPrivateKeyPath(request.getPrivateKeyPath()); + effective.setPassPhrase(request.getPassPhrase()); + effective.setRootPath(request.getRootPath()); + } + + if (effective.getHost() == null || effective.getHost().isEmpty()) { + throw new Exception("主机地址不能为空"); + } + if (effective.getUsername() == null || effective.getUsername().isEmpty()) { + throw new Exception("用户名不能为空"); + } + if ((effective.getPassword() == null || effective.getPassword().isEmpty()) + && (effective.getPrivateKeyPath() == null || effective.getPrivateKeyPath().isEmpty())) { + throw new Exception("密码和私钥不能同时为空"); + } + + if (effective.getPort() == null || effective.getPort() <= 0) { + effective.setPort(22); + } + + return effective; + } + + private void configureKnownHosts(JSch jsch) throws Exception { + String configuredPath = knownHostsPath == null ? "" : knownHostsPath.trim(); + + if (!configuredPath.isEmpty()) { + jsch.setKnownHosts(configuredPath); + return; + } + + String defaultPath = System.getProperty("user.home") + File.separator + ".ssh" + File.separator + "known_hosts"; + File knownHosts = new File(defaultPath); + if (!knownHosts.exists() || !knownHosts.isFile()) { + throw new Exception("主机密钥校验已启用,但未找到 known_hosts 文件,请配置 app.sftp.known-hosts-path"); + } + + jsch.setKnownHosts(defaultPath); + } + public void disconnect(String sessionId) { + log.info("断开SFTP会话: sessionId={}", sessionId); sessionManager.removeSession(sessionId); } diff --git a/src/main/java/com/sftp/manager/service/LocalFileService.java b/src/main/java/com/sftp/manager/service/LocalFileService.java index feea881..a6bafb9 100644 --- a/src/main/java/com/sftp/manager/service/LocalFileService.java +++ b/src/main/java/com/sftp/manager/service/LocalFileService.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.FileVisitOption; @@ -16,8 +17,8 @@ import java.nio.file.attribute.BasicFileAttributes; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; -import java.util.EnumSet; import java.util.List; +import java.util.Locale; @Service public class LocalFileService { @@ -28,10 +29,10 @@ public class LocalFileService { public List listFiles(String path, boolean showHidden) throws Exception { List files = new ArrayList<>(); - File directory = new File(path); + File directory = normalizeFile(path); if (!directory.exists() || !directory.isDirectory()) { - throw new Exception("目录不存在: " + path); + throw new Exception("目录不存在: " + directory.getPath()); } File[] fileArray = directory.listFiles(); @@ -69,13 +70,17 @@ public class LocalFileService { } public boolean fileExists(String path) { - return new File(path).exists(); + try { + return normalizeFile(path).exists(); + } catch (Exception e) { + return false; + } } public FileInfo getFileInfo(String path) throws Exception { - File file = new File(path); + File file = normalizeFile(path); if (!file.exists()) { - throw new Exception("文件不存在: " + path); + throw new Exception("文件不存在: " + file.getPath()); } FileInfo fileInfo = new FileInfo(); @@ -94,17 +99,20 @@ public class LocalFileService { } public String getParentPath(String path) { - File file = new File(path); - String parent = file.getParent(); - return parent; + try { + File file = normalizeFile(path); + return file.getParent(); + } catch (Exception e) { + return null; + } } // 上传本地文件到SFTP public void uploadToSftp(String localPath, String sessionId, - String remotePath, SftpService sftpService) throws Exception { - File file = new File(localPath); + String remotePath, SftpService sftpService) throws Exception { + File file = normalizeFile(localPath); if (!file.exists()) { - throw new Exception("本地文件不存在: " + localPath); + throw new Exception("本地文件不存在: " + file.getPath()); } try (InputStream inputStream = new FileInputStream(file)) { @@ -121,9 +129,9 @@ public class LocalFileService { */ public void uploadDirectoryToSftp(String localDirPath, String sessionId, String remoteDirPath, SftpService sftpService) throws Exception { - File root = new File(localDirPath); + File root = normalizeFile(localDirPath); if (!root.exists() || !root.isDirectory()) { - throw new Exception("本地目录不存在: " + localDirPath); + throw new Exception("本地目录不存在: " + root.getPath()); } Path rootPath = root.toPath(); @@ -170,7 +178,7 @@ public class LocalFileService { // 从SFTP下载到本地 public void downloadFromSftp(String sessionId, String remotePath, String localPath, SftpService sftpService) throws Exception { - File file = new File(localPath); + File file = normalizeFile(localPath); File parentDir = file.getParentFile(); // 确保父目录存在 @@ -192,9 +200,9 @@ public class LocalFileService { */ public void downloadDirectoryFromSftp(String sessionId, String remoteDirPath, String localDirPath, SftpService sftpService) throws Exception { - File root = new File(localDirPath); + File root = normalizeFile(localDirPath); if (!root.exists() && !root.mkdirs()) { - throw new Exception("创建本地目录失败: " + localDirPath); + throw new Exception("创建本地目录失败: " + root.getPath()); } downloadDirectoryFromSftpRecursive(sessionId, remoteDirPath, root, sftpService); } @@ -226,9 +234,9 @@ public class LocalFileService { // 删除单个文件或目录 public boolean deleteFile(String path) throws Exception { - File file = new File(path); + File file = normalizeFile(path); if (!file.exists()) { - throw new Exception("文件不存在: " + path); + throw new Exception("文件不存在: " + file.getPath()); } checkDeletePermission(file); if (file.isDirectory()) { @@ -257,27 +265,31 @@ public class LocalFileService { // 检查删除权限(本地) private void checkDeletePermission(File file) throws Exception { - if (!file.canWrite()) { - throw new Exception("没有删除权限: " + file.getPath()); + File canonicalFile = file.getCanonicalFile(); + String canonicalPath = canonicalFile.getPath(); + + // 禁止删除根目录 + if (isRootPath(canonicalFile)) { + throw new Exception("根目录禁止删除: " + canonicalPath); } - String systemPaths = "C:\\Windows,C:\\Program Files,C:\\Program Files (x86),C:\\System32"; - String[] paths = systemPaths.split(","); - for (String systemPath : paths) { - String p = systemPath.trim().toLowerCase(); - if (p.isEmpty()) continue; - if (file.getPath().toLowerCase().startsWith(p)) { - throw new Exception("系统目录禁止删除: " + file.getPath()); - } + + // 保护系统关键目录 + if (isProtectedSystemPath(canonicalPath)) { + throw new Exception("系统目录禁止删除: " + canonicalPath); + } + + if (!file.canWrite()) { + throw new Exception("没有删除权限: " + canonicalPath); } } // 重命名文件 public boolean renameFile(String oldPath, String newPath) throws Exception { - File oldFile = new File(oldPath); - File newFile = new File(newPath); + File oldFile = normalizeFile(oldPath); + File newFile = normalizeFile(newPath); if (!oldFile.exists()) { - throw new Exception("源文件不存在: " + oldPath); + throw new Exception("源文件不存在: " + oldFile.getPath()); } if (newFile.exists()) { @@ -289,6 +301,13 @@ public class LocalFileService { throw new Exception("文件名包含非法字符: " + newFileName); } + File oldParent = oldFile.getParentFile(); + File newParent = newFile.getParentFile(); + if (oldParent == null || newParent == null + || !oldParent.getCanonicalPath().equals(newParent.getCanonicalPath())) { + throw new Exception("仅支持同目录重命名"); + } + boolean result = oldFile.renameTo(newFile); if (!result) { throw new Exception("重命名失败"); @@ -347,7 +366,7 @@ public class LocalFileService { // 创建目录(单级,父目录必须存在) public boolean createDirectory(String path) throws Exception { - File directory = new File(path); + File directory = normalizeFile(path); if (directory.exists()) { throw new Exception("目录已存在: " + path); @@ -373,7 +392,7 @@ public class LocalFileService { // 创建多级目录 public boolean createDirectories(String path) throws Exception { - File directory = new File(path); + File directory = normalizeFile(path); if (directory.exists()) { throw new Exception("目录已存在: " + path); @@ -453,4 +472,81 @@ public class LocalFileService { result.setFailedFiles(failedFiles); return result; } + + private File normalizeFile(String path) throws Exception { + if (path == null || path.trim().isEmpty()) { + throw new Exception("路径不能为空"); + } + + String raw = path.trim(); + if (raw.indexOf('\0') >= 0) { + throw new Exception("路径包含非法字符"); + } + if (containsParentTraversal(raw)) { + throw new Exception("路径不能包含上级目录引用(..): " + raw); + } + + try { + return new File(raw).getCanonicalFile(); + } catch (IOException e) { + throw new Exception("路径无效: " + raw, e); + } + } + + private boolean containsParentTraversal(String path) { + String[] parts = path.replace('\\', '/').split("/"); + for (String part : parts) { + if ("..".equals(part)) { + return true; + } + } + return false; + } + + private boolean isRootPath(File file) { + Path p = file.toPath().normalize(); + Path root = p.getRoot(); + return root != null && root.equals(p); + } + + private boolean isProtectedSystemPath(String path) { + String normalized = path.replace('\\', '/'); + String normalizedLower = normalized.toLowerCase(Locale.ROOT); + + String[] windowsPaths = { + "c:/windows", + "c:/program files", + "c:/program files (x86)", + "c:/system32" + }; + String[] unixPaths = { + "/bin", + "/boot", + "/dev", + "/etc", + "/lib", + "/lib64", + "/proc", + "/root", + "/sbin", + "/sys", + "/usr", + "/var" + }; + + if (matchProtectedPath(normalizedLower, windowsPaths)) { + return true; + } + + return matchProtectedPath(normalizedLower, unixPaths); + } + + private boolean matchProtectedPath(String target, String[] protectedPaths) { + for (String protectedPath : protectedPaths) { + if (target.equals(protectedPath) || target.startsWith(protectedPath + "/")) { + return true; + } + } + return false; + } } diff --git a/src/main/java/com/sftp/manager/service/SessionManager.java b/src/main/java/com/sftp/manager/service/SessionManager.java index e9b9415..4cbb49f 100644 --- a/src/main/java/com/sftp/manager/service/SessionManager.java +++ b/src/main/java/com/sftp/manager/service/SessionManager.java @@ -2,8 +2,13 @@ package com.sftp.manager.service; import com.jcraft.jsch.ChannelSftp; import com.sftp.manager.model.Connection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.util.Iterator; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -11,18 +16,35 @@ import java.util.concurrent.ConcurrentHashMap; @Component public class SessionManager { + private static final Logger log = LoggerFactory.getLogger(SessionManager.class); + private final Map activeSessions = new ConcurrentHashMap<>(); private final Map sessionConnections = new ConcurrentHashMap<>(); + private final Map sessionLastAccessTime = new ConcurrentHashMap<>(); + + @Value("${app.sftp.session-timeout:30000}") + private long sessionTimeout; public String addSession(ChannelSftp channel, Connection connection) { String sessionId = "sftp-" + UUID.randomUUID().toString(); activeSessions.put(sessionId, channel); sessionConnections.put(sessionId, connection); + sessionLastAccessTime.put(sessionId, System.currentTimeMillis()); + if (connection != null) { + log.info("新增SFTP会话: sessionId={}, host={}, username={}", + sessionId, connection.getHost(), connection.getUsername()); + } else { + log.info("新增SFTP会话: sessionId={}", sessionId); + } return sessionId; } public ChannelSftp getSession(String sessionId) { - return activeSessions.get(sessionId); + ChannelSftp channel = activeSessions.get(sessionId); + if (channel != null) { + sessionLastAccessTime.put(sessionId, System.currentTimeMillis()); + } + return channel; } public Connection getConnection(String sessionId) { @@ -30,16 +52,19 @@ public class SessionManager { } public void removeSession(String sessionId) { + log.info("移除SFTP会话: sessionId={}", sessionId); ChannelSftp channel = activeSessions.get(sessionId); if (channel != null) { try { channel.disconnect(); } catch (Exception e) { // 忽略关闭异常 + log.debug("关闭SFTP会话异常: sessionId={}, error={}", sessionId, e.getMessage()); } } activeSessions.remove(sessionId); sessionConnections.remove(sessionId); + sessionLastAccessTime.remove(sessionId); } public boolean isActive(String sessionId) { @@ -53,4 +78,41 @@ public class SessionManager { public int getActiveSessionCount() { return activeSessions.size(); } + + @Scheduled(fixedDelayString = "${app.sftp.session-cleanup-interval:60000}") + public void cleanupExpiredSessions() { + long now = System.currentTimeMillis(); + + Iterator> iterator = activeSessions.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String sessionId = entry.getKey(); + ChannelSftp channel = entry.getValue(); + Long lastAccess = sessionLastAccessTime.get(sessionId); + + boolean disconnected = channel == null || !channel.isConnected(); + boolean expired = lastAccess == null || (now - lastAccess) > sessionTimeout; + + if (disconnected || expired) { + if (channel != null) { + try { + channel.disconnect(); + } catch (Exception e) { + // 忽略清理阶段的关闭异常 + log.debug("清理会话时关闭异常: sessionId={}, error={}", sessionId, e.getMessage()); + } + } + + if (expired) { + log.info("清理超时会话: sessionId={}, timeoutMs={}", sessionId, sessionTimeout); + } else { + log.info("清理断开会话: sessionId={}", sessionId); + } + + iterator.remove(); + sessionConnections.remove(sessionId); + sessionLastAccessTime.remove(sessionId); + } + } + } } diff --git a/src/main/java/com/sftp/manager/service/SftpService.java b/src/main/java/com/sftp/manager/service/SftpService.java index fa08c81..24173e7 100644 --- a/src/main/java/com/sftp/manager/service/SftpService.java +++ b/src/main/java/com/sftp/manager/service/SftpService.java @@ -5,6 +5,8 @@ import com.jcraft.jsch.SftpATTRS; import com.jcraft.jsch.SftpException; import com.sftp.manager.dto.BatchDeleteResult; import com.sftp.manager.model.FileInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -22,6 +24,8 @@ import java.util.Vector; @Service public class SftpService { + private static final Logger log = LoggerFactory.getLogger(SftpService.class); + @Autowired private SessionManager sessionManager; @@ -30,6 +34,7 @@ public class SftpService { } public List listFiles(String sessionId, String path, boolean showHidden) throws Exception { + log.debug("列出SFTP文件: sessionId={}, path={}, showHidden={}", sessionId, path, showHidden); ChannelSftp channel = sessionManager.getSession(sessionId); if (channel == null) { throw new Exception("会话不存在或已断开"); @@ -72,6 +77,7 @@ public class SftpService { return files; } catch (SftpException e) { + log.warn("列出SFTP文件失败: sessionId={}, path={}, error={}", sessionId, path, e.getMessage()); throw new Exception("列出文件失败: " + e.getMessage(), e); } } @@ -139,7 +145,8 @@ public class SftpService { // 上传文件到SFTP public void uploadFile(String sessionId, InputStream inputStream, - String remotePath, long fileSize) throws Exception { + String remotePath, long fileSize) throws Exception { + log.info("上传到SFTP: sessionId={}, remotePath={}, fileSize={}", sessionId, remotePath, fileSize); ChannelSftp channel = sessionManager.getSession(sessionId); if (channel == null) { throw new Exception("会话不存在或已断开"); @@ -148,6 +155,7 @@ public class SftpService { try { channel.put(inputStream, remotePath); } catch (SftpException e) { + log.warn("上传到SFTP失败: sessionId={}, remotePath={}, error={}", sessionId, remotePath, e.getMessage()); throw new Exception("上传失败: " + e.getMessage(), e); } // 注意:不在此处关闭流,由调用者使用try-with-resources管理 @@ -155,7 +163,8 @@ public class SftpService { // 从SFTP下载文件 public void downloadFile(String sessionId, String remotePath, - OutputStream outputStream) throws Exception { + OutputStream outputStream) throws Exception { + log.info("从SFTP下载: sessionId={}, remotePath={}", sessionId, remotePath); ChannelSftp channel = sessionManager.getSession(sessionId); if (channel == null) { throw new Exception("会话不存在或已断开"); @@ -164,6 +173,7 @@ public class SftpService { try { channel.get(remotePath, outputStream); } catch (SftpException e) { + log.warn("从SFTP下载失败: sessionId={}, remotePath={}, error={}", sessionId, remotePath, e.getMessage()); throw new Exception("下载失败: " + e.getMessage(), e); } // 注意:不在此处关闭流,由调用者使用try-with-resources管理 @@ -171,7 +181,9 @@ public class SftpService { // SFTP间传输 public void transferBetweenSftp(String sourceSessionId, String sourcePath, - String targetSessionId, String targetPath) throws Exception { + String targetSessionId, String targetPath) throws Exception { + log.info("SFTP间传输开始: sourceSessionId={}, sourcePath={}, targetSessionId={}, targetPath={}", + sourceSessionId, sourcePath, targetSessionId, targetPath); // 创建临时文件 String tempDir = System.getProperty("java.io.tmpdir"); String tempFile = tempDir + File.separator + UUID.randomUUID().toString(); @@ -192,6 +204,8 @@ public class SftpService { } targetChannel.put(tempFile, targetPath); + log.info("SFTP间传输成功: sourceSessionId={}, sourcePath={}, targetSessionId={}, targetPath={}", + sourceSessionId, sourcePath, targetSessionId, targetPath); } finally { // 删除临时文件 @@ -204,6 +218,7 @@ public class SftpService { // 删除单个文件或目录 public boolean deleteFile(String sessionId, String path) throws Exception { + log.info("删除SFTP文件: sessionId={}, path={}", sessionId, path); ChannelSftp channel = sessionManager.getSession(sessionId); if (channel == null) { throw new Exception("会话不存在或已断开"); @@ -218,6 +233,7 @@ public class SftpService { } return true; } catch (SftpException e) { + log.warn("删除SFTP文件失败: sessionId={}, path={}, error={}", sessionId, path, e.getMessage()); throw new Exception("删除失败: " + e.getMessage(), e); } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5f2b4af..c801842 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -27,10 +27,15 @@ spring: max-request-size: 2GB app: + cors: + allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:48081} sftp: session-timeout: 60000 + session-cleanup-interval: 60000 connection-timeout: 30000 max-retries: 5 + strict-host-key-checking: ${SFTP_STRICT_HOST_KEY_CHECKING:false} + known-hosts-path: ${KNOWN_HOSTS_PATH:} # Actuator 生产环境暴露健康与指标 management: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e2959d3..9e088cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -34,10 +34,15 @@ spring: # 自定义配置 app: + cors: + allowed-origins: http://localhost:48081,http://127.0.0.1:48081 sftp: session-timeout: 30000 # SFTP会话超时时间(ms) + session-cleanup-interval: 60000 # 会话清理周期(ms) connection-timeout: 10000 # 连接超时时间(ms) max-retries: 3 # 连接失败重试次数 + strict-host-key-checking: false # 开发环境默认关闭,生产建议开启 + known-hosts-path: ${KNOWN_HOSTS_PATH:} # Actuator 监控端点(开发环境仅暴露 health、info) management: diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 2d276bf..fa8f23c 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -859,12 +859,10 @@ function connectToServer(connId) { method: 'POST', contentType: 'application/json', data: JSON.stringify({ + id: conn.id, host: conn.host, port: conn.port, - username: conn.username, - password: conn.password, - privateKeyPath: conn.privateKeyPath, - passPhrase: conn.passPhrase + username: conn.username }), success: function(res) { if (res.success) { diff --git a/src/test/java/com/sftp/manager/controller/ConnectionControllerTest.java b/src/test/java/com/sftp/manager/controller/ConnectionControllerTest.java new file mode 100644 index 0000000..24ec192 --- /dev/null +++ b/src/test/java/com/sftp/manager/controller/ConnectionControllerTest.java @@ -0,0 +1,78 @@ +package com.sftp.manager.controller; + +import com.sftp.manager.dto.ConnectionRequest; +import com.sftp.manager.dto.DisconnectRequest; +import com.sftp.manager.dto.ApiResponse; +import com.sftp.manager.model.Connection; +import com.sftp.manager.service.ConnectionService; +import com.sftp.manager.service.SessionManager; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Map; + +public class ConnectionControllerTest { + + private ConnectionController controller; + private ConnectionService connectionService; + private SessionManager sessionManager; + + @BeforeEach + public void setUp() { + controller = new ConnectionController(); + connectionService = Mockito.mock(ConnectionService.class); + sessionManager = Mockito.mock(SessionManager.class); + + ReflectionTestUtils.setField(controller, "connectionService", connectionService); + ReflectionTestUtils.setField(controller, "sessionManager", sessionManager); + } + + @Test + public void disconnect_shouldThrowWhenSessionIdEmpty() { + DisconnectRequest request = new DisconnectRequest(); + request.setSessionId(" "); + + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> controller.disconnect(request)); + + Assertions.assertEquals("会话ID不能为空", ex.getMessage()); + } + + @Test + public void listConnections_shouldReturnSuccessResponse() { + Mockito.when(connectionService.listConnections()).thenReturn(Collections.emptyList()); + + ApiResponse> response = controller.listConnections(); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertEquals("查询成功", response.getMessage()); + } + + @Test + public void getActiveConnections_shouldReturnSuccessResponse() { + Mockito.when(sessionManager.getAllActiveConnections()).thenReturn(Collections.emptyMap()); + + ApiResponse> response = controller.getActiveConnections(); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertEquals("查询成功", response.getMessage()); + } + + @Test + public void connect_shouldDelegateToService() throws Exception { + ConnectionRequest request = new ConnectionRequest(); + request.setHost("127.0.0.1"); + request.setUsername("root"); + + Mockito.when(connectionService.connect(request)).thenReturn("sftp-1"); + + ApiResponse response = controller.connect(request); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertEquals("sftp-1", response.getData()); + } +} diff --git a/src/test/java/com/sftp/manager/controller/FileControllerTest.java b/src/test/java/com/sftp/manager/controller/FileControllerTest.java new file mode 100644 index 0000000..cf6f798 --- /dev/null +++ b/src/test/java/com/sftp/manager/controller/FileControllerTest.java @@ -0,0 +1,83 @@ +package com.sftp.manager.controller; + +import com.sftp.manager.dto.ApiResponse; +import com.sftp.manager.dto.BatchDeleteRequest; +import com.sftp.manager.dto.BatchDeleteResult; +import com.sftp.manager.dto.DirectoryRequest; +import com.sftp.manager.dto.FileListRequest; +import com.sftp.manager.service.LocalFileService; +import com.sftp.manager.service.SessionManager; +import com.sftp.manager.service.SftpService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; + +public class FileControllerTest { + + private FileController controller; + private LocalFileService localFileService; + private SftpService sftpService; + + @BeforeEach + public void setUp() { + controller = new FileController(); + localFileService = Mockito.mock(LocalFileService.class); + sftpService = Mockito.mock(SftpService.class); + SessionManager sessionManager = Mockito.mock(SessionManager.class); + + ReflectionTestUtils.setField(controller, "localFileService", localFileService); + ReflectionTestUtils.setField(controller, "sftpService", sftpService); + ReflectionTestUtils.setField(controller, "sessionManager", sessionManager); + } + + @Test + public void listFiles_shouldThrowWhenSessionIdEmpty() { + FileListRequest request = new FileListRequest(); + request.setSessionId(" "); + request.setPath("/tmp"); + + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> controller.listFiles(request)); + + Assertions.assertEquals("会话ID不能为空", ex.getMessage()); + } + + @Test + public void createDirectory_local_shouldCallLocalService() throws Exception { + DirectoryRequest request = new DirectoryRequest(); + request.setSessionId("local"); + request.setPath("/tmp/new-dir"); + + Mockito.when(localFileService.createDirectory("/tmp/new-dir")).thenReturn(true); + + ApiResponse response = controller.createDirectory(request); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertEquals("创建成功", response.getMessage()); + Mockito.verify(localFileService).createDirectory("/tmp/new-dir"); + } + + @Test + public void batchDelete_nullPaths_shouldUseEmptyList() { + BatchDeleteRequest request = new BatchDeleteRequest(); + request.setSessionId("local"); + request.setPaths(null); + + BatchDeleteResult result = new BatchDeleteResult(); + result.setSuccessCount(0); + result.setFailCount(0); + result.setFailedFiles(Collections.emptyList()); + + Mockito.when(localFileService.batchDelete(Mockito.anyList())).thenReturn(result); + + ApiResponse response = controller.batchDelete(request); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Mockito.verify(localFileService).batchDelete(Mockito.anyList()); + } +} diff --git a/src/test/java/com/sftp/manager/service/ConnectionServiceTest.java b/src/test/java/com/sftp/manager/service/ConnectionServiceTest.java new file mode 100644 index 0000000..59334f6 --- /dev/null +++ b/src/test/java/com/sftp/manager/service/ConnectionServiceTest.java @@ -0,0 +1,115 @@ +package com.sftp.manager.service; + +import com.sftp.manager.dto.ConnectionRequest; +import com.sftp.manager.model.Connection; +import com.sftp.manager.repository.ConnectionRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +public class ConnectionServiceTest { + + private ConnectionService connectionService; + private ConnectionRepository connectionRepository; + + @BeforeEach + public void setUp() { + connectionService = new ConnectionService(); + connectionRepository = Mockito.mock(ConnectionRepository.class); + + ReflectionTestUtils.setField(connectionService, "connectionRepository", connectionRepository); + ReflectionTestUtils.setField(connectionService, "sessionManager", Mockito.mock(SessionManager.class)); + } + + @Test + public void buildEffectiveRequest_shouldLoadSavedCredentialsById() throws Exception { + Connection saved = new Connection(); + saved.setId(1L); + saved.setName("saved-conn"); + saved.setHost("10.0.0.1"); + saved.setPort(null); + saved.setUsername("saved-user"); + saved.setPassword("saved-pass"); + saved.setPrivateKeyPath("/tmp/id_rsa"); + saved.setPassPhrase("pp"); + saved.setRootPath("/home/saved"); + + Mockito.when(connectionRepository.findById(1L)).thenReturn(Optional.of(saved)); + + ConnectionRequest request = new ConnectionRequest(); + request.setId(1L); + request.setHost("10.0.0.2"); + + ConnectionRequest effective = invokeBuildEffectiveRequest(request); + + Assertions.assertEquals("10.0.0.2", effective.getHost()); + Assertions.assertEquals("saved-user", effective.getUsername()); + Assertions.assertEquals("saved-pass", effective.getPassword()); + Assertions.assertEquals(Integer.valueOf(22), effective.getPort()); + } + + @Test + public void buildEffectiveRequest_shouldFailWhenNoPasswordAndNoPrivateKey() { + ConnectionRequest request = new ConnectionRequest(); + request.setHost("127.0.0.1"); + request.setUsername("root"); + + Exception ex = Assertions.assertThrows(Exception.class, () -> invokeBuildEffectiveRequest(request)); + Assertions.assertEquals("密码和私钥不能同时为空", ex.getMessage()); + } + + @Test + public void configureKnownHosts_shouldFailWhenDefaultKnownHostsMissing() { + String oldUserHome = System.getProperty("user.home"); + try { + Path tempHome = Files.createTempDirectory("sftp-manager-home"); + System.setProperty("user.home", tempHome.toString()); + ReflectionTestUtils.setField(connectionService, "knownHostsPath", ""); + + Exception ex = Assertions.assertThrows(Exception.class, this::invokeConfigureKnownHosts); + Assertions.assertTrue(ex.getMessage().contains("未找到 known_hosts")); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + if (oldUserHome != null) { + System.setProperty("user.home", oldUserHome); + } + } + } + + private ConnectionRequest invokeBuildEffectiveRequest(ConnectionRequest request) throws Exception { + Method method = ConnectionService.class.getDeclaredMethod("buildEffectiveRequest", ConnectionRequest.class); + method.setAccessible(true); + try { + return (ConnectionRequest) method.invoke(connectionService, request); + } catch (InvocationTargetException e) { + Throwable target = e.getTargetException(); + if (target instanceof Exception) { + throw (Exception) target; + } + throw new RuntimeException(target); + } + } + + private void invokeConfigureKnownHosts() throws Exception { + Method method = ConnectionService.class.getDeclaredMethod("configureKnownHosts", com.jcraft.jsch.JSch.class); + method.setAccessible(true); + try { + method.invoke(connectionService, new com.jcraft.jsch.JSch()); + } catch (InvocationTargetException e) { + Throwable target = e.getTargetException(); + if (target instanceof Exception) { + throw (Exception) target; + } + throw new RuntimeException(target); + } + } +} diff --git a/src/test/java/com/sftp/manager/service/LocalFileServiceTest.java b/src/test/java/com/sftp/manager/service/LocalFileServiceTest.java new file mode 100644 index 0000000..63a9144 --- /dev/null +++ b/src/test/java/com/sftp/manager/service/LocalFileServiceTest.java @@ -0,0 +1,77 @@ +package com.sftp.manager.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class LocalFileServiceTest { + + private final LocalFileService localFileService = new LocalFileService(); + + @TempDir + Path tempDir; + + @Test + public void createDirectory_shouldRejectParentTraversalPath() { + String badPath = tempDir.resolve("../escape-dir").toString(); + + Exception ex = Assertions.assertThrows(Exception.class, + () -> localFileService.createDirectory(badPath)); + + Assertions.assertTrue(ex.getMessage().contains("上级目录引用")); + } + + @Test + public void deleteFile_shouldRejectRootPath() { + File[] roots = File.listRoots(); + Assertions.assertNotNull(roots); + Assertions.assertTrue(roots.length > 0); + + String rootPath = roots[0].getPath(); + + Exception ex = Assertions.assertThrows(Exception.class, + () -> localFileService.deleteFile(rootPath)); + + Assertions.assertTrue(ex.getMessage().contains("根目录禁止删除")); + } + + @Test + public void renameFile_shouldRejectCrossDirectoryRename() throws Exception { + Path source = tempDir.resolve("source.txt"); + Files.write(source, "data".getBytes(StandardCharsets.UTF_8)); + + Path subDir = tempDir.resolve("sub"); + Files.createDirectories(subDir); + Path target = subDir.resolve("target.txt"); + + Exception ex = Assertions.assertThrows(Exception.class, + () -> localFileService.renameFile(source.toString(), target.toString())); + + Assertions.assertTrue(ex.getMessage().contains("仅支持同目录重命名")); + } + + @Test + public void renameFile_shouldAllowRenameInSameDirectory() throws Exception { + Path source = tempDir.resolve("old-name.txt"); + Files.write(source, "data".getBytes(StandardCharsets.UTF_8)); + Path target = tempDir.resolve("new-name.txt"); + + boolean result = localFileService.renameFile(source.toString(), target.toString()); + + Assertions.assertTrue(result); + Assertions.assertFalse(Files.exists(source)); + Assertions.assertTrue(Files.exists(target)); + } + + @Test + public void fileExists_shouldReturnFalseWhenPathContainsTraversal() { + boolean exists = localFileService.fileExists("../sensitive-path"); + + Assertions.assertFalse(exists); + } +} diff --git a/src/test/java/com/sftp/manager/service/SessionManagerTest.java b/src/test/java/com/sftp/manager/service/SessionManagerTest.java new file mode 100644 index 0000000..4986f98 --- /dev/null +++ b/src/test/java/com/sftp/manager/service/SessionManagerTest.java @@ -0,0 +1,81 @@ +package com.sftp.manager.service; + +import com.jcraft.jsch.ChannelSftp; +import com.sftp.manager.model.Connection; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +public class SessionManagerTest { + + @Test + public void cleanupExpiredSessions_shouldRemoveDisconnectedSession() { + SessionManager sessionManager = new SessionManager(); + ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L); + + ChannelSftp channel = Mockito.mock(ChannelSftp.class); + Mockito.when(channel.isConnected()).thenReturn(false); + + Connection connection = new Connection(); + connection.setName("test"); + + String sessionId = sessionManager.addSession(channel, connection); + + sessionManager.cleanupExpiredSessions(); + + Assertions.assertNull(sessionManager.getSession(sessionId)); + Assertions.assertNull(sessionManager.getConnection(sessionId)); + } + + @Test + public void cleanupExpiredSessions_shouldRemoveTimeoutSession() { + SessionManager sessionManager = new SessionManager(); + ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 100L); + + ChannelSftp channel = Mockito.mock(ChannelSftp.class); + Mockito.when(channel.isConnected()).thenReturn(true); + + Connection connection = new Connection(); + connection.setName("timeout-test"); + + String sessionId = sessionManager.addSession(channel, connection); + + @SuppressWarnings("unchecked") + Map accessMap = (Map) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime"); + Assertions.assertNotNull(accessMap); + accessMap.put(sessionId, System.currentTimeMillis() - 10_000L); + + sessionManager.cleanupExpiredSessions(); + + Assertions.assertNull(sessionManager.getSession(sessionId)); + Assertions.assertNull(sessionManager.getConnection(sessionId)); + } + + @Test + public void getSession_shouldRefreshLastAccessTime() throws Exception { + SessionManager sessionManager = new SessionManager(); + ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L); + + ChannelSftp channel = Mockito.mock(ChannelSftp.class); + Mockito.when(channel.isConnected()).thenReturn(true); + + Connection connection = new Connection(); + connection.setName("access-test"); + + String sessionId = sessionManager.addSession(channel, connection); + + @SuppressWarnings("unchecked") + Map accessMap = (Map) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime"); + Assertions.assertNotNull(accessMap); + accessMap.put(sessionId, 1L); + + sessionManager.getSession(sessionId); + + Long refreshedTime = accessMap.get(sessionId); + Assertions.assertNotNull(refreshedTime); + Assertions.assertTrue(refreshedTime > 1L); + } +}