Compare commits
12 Commits
7e6ebd18a5
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f37074f5a | |||
| a10906d711 | |||
|
|
a61a88f36b | ||
|
|
e792fb919d | ||
|
|
7f57d69756 | ||
|
|
a1b8a4af8c | ||
|
|
ea38d1c026 | ||
|
|
a67562bfea | ||
|
|
b82ea1919e | ||
|
|
1aefc14e42 | ||
|
|
669dc11064 | ||
|
|
4558ef20c0 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts: always LF so ./start.sh works on Linux/WSL
|
||||||
|
*.sh text eol=lf
|
||||||
159
AGENTS.md
Normal file
159
AGENTS.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# AGENTS 指南(ssh-manager)
|
||||||
|
|
||||||
|
本文件面向在本仓库工作的自动化 coding agents。
|
||||||
|
目标:快速、安全、风格一致地完成改动。
|
||||||
|
|
||||||
|
## 1) 仓库概览
|
||||||
|
|
||||||
|
- Monorepo:`backend`(Spring Boot 2.7 + Java 8)+ `frontend`(Vue 3 + TS + Vite)
|
||||||
|
- 默认后端端口:`48080`(见 `backend/src/main/resources/application.yml`)
|
||||||
|
- 前端开发端口:`5173`,通过 Vite 代理 `/api` 和 `/ws`
|
||||||
|
- 数据库:H2 文件库(`./data/sshmanager`)
|
||||||
|
- 认证:JWT(HTTP Header Bearer + WebSocket query token)
|
||||||
|
|
||||||
|
## 2) 环境与依赖
|
||||||
|
|
||||||
|
- JDK:8+
|
||||||
|
- Maven:3.6+
|
||||||
|
- Node.js:18+
|
||||||
|
- npm:与 `frontend/package-lock.json` 配套
|
||||||
|
|
||||||
|
## 3) 常用命令(构建 / 检查 / 测试)
|
||||||
|
|
||||||
|
### 3.1 后端(在 `backend/` 目录)
|
||||||
|
|
||||||
|
- 启动开发服务:`mvn spring-boot:run`
|
||||||
|
- 打包:`mvn package`
|
||||||
|
- 仅运行测试:`mvn test`
|
||||||
|
- 跳过测试打包:`mvn -DskipTests package`
|
||||||
|
- 运行单个测试类:`mvn -Dtest=ConnectionServiceTest test`
|
||||||
|
- 运行单个测试方法:`mvn -Dtest=ConnectionServiceTest#shouldCreateConnection test`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 当前仓库可能暂无 `src/test` 用例;上述命令是 Maven 标准入口。
|
||||||
|
- 新增测试时优先使用 Surefire 默认命名约定(`*Test`、`*Tests`)。
|
||||||
|
|
||||||
|
### 3.2 前端(在 `frontend/` 目录)
|
||||||
|
|
||||||
|
- 安装依赖:`npm install`
|
||||||
|
- 启动开发服务:`npm run dev`
|
||||||
|
- 生产构建:`npm run build`
|
||||||
|
- 本地预览构建产物:`npm run preview`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 当前 `package.json` 未配置独立 lint/test script。
|
||||||
|
- `npm run build` 会先执行 `vue-tsc -b`,可视为类型检查关卡。
|
||||||
|
|
||||||
|
### 3.3 Docker(在仓库根目录)
|
||||||
|
|
||||||
|
- 构建镜像:`docker compose -f docker/docker-compose.yml build`
|
||||||
|
- 前台运行:`docker compose -f docker/docker-compose.yml up`
|
||||||
|
- 后台运行:`docker compose -f docker/docker-compose.yml up -d`
|
||||||
|
|
||||||
|
## 4) 单测执行策略(重点)
|
||||||
|
|
||||||
|
- 后端单测首选:`mvn -Dtest=类名#方法名 test`
|
||||||
|
- 多方法可用逗号:`mvn -Dtest=ClassTest#testA,testB test`
|
||||||
|
- 若测试依赖 Spring 上下文,优先在类级隔离,避免全量启动过慢
|
||||||
|
- 修改 Service/Controller 后,至少补一条对应单元或集成测试(若仓库开始建设测试)
|
||||||
|
- 前端若后续引入 Vitest,建议约定:
|
||||||
|
- 全量:`npm run test`
|
||||||
|
- 单文件:`npm run test -- src/views/ConnectionsView.test.ts`
|
||||||
|
- 单用例:`npm run test -- -t "case name"`
|
||||||
|
|
||||||
|
## 5) 代码风格总则
|
||||||
|
|
||||||
|
- 小步提交:改动聚焦,避免“顺手重构”无关文件
|
||||||
|
- 保持现有技术栈,不随意引入新框架/重依赖
|
||||||
|
- 默认不修改对外 API 语义;若必须修改,同步更新调用侧
|
||||||
|
- 安全优先:严禁提交真实密钥、密码、token、私钥
|
||||||
|
- 可观测性:失败路径要返回可诊断信息(但不能泄露敏感数据)
|
||||||
|
|
||||||
|
## 6) 后端风格(Java / Spring)
|
||||||
|
|
||||||
|
### 6.1 导入与结构
|
||||||
|
|
||||||
|
- 包名固定前缀:`com.sshmanager`
|
||||||
|
- import 分组遵循现有习惯:
|
||||||
|
- 先项目内 `com.sshmanager...`
|
||||||
|
- 再第三方/框架 `org...` / `javax...`
|
||||||
|
- 最后 `java...`
|
||||||
|
- 避免通配符 import(`*`)
|
||||||
|
|
||||||
|
### 6.2 格式与命名
|
||||||
|
|
||||||
|
- 4 空格缩进,左花括号同行
|
||||||
|
- 类名 `PascalCase`,方法/变量 `camelCase`
|
||||||
|
- Controller 以 `*Controller`,Service 以 `*Service`,Repository 以 `*Repository`
|
||||||
|
- DTO 放 `dto` 包,实体放 `entity` 包
|
||||||
|
- 常量使用 `UPPER_SNAKE_CASE`
|
||||||
|
|
||||||
|
### 6.3 类型与注解
|
||||||
|
|
||||||
|
- Java 8 语法兼容(不要引入更高版本语法特性)
|
||||||
|
- 优先构造器注入(本仓库已有统一做法)
|
||||||
|
- 合理使用 Lombok(当前主要在 Entity 使用)
|
||||||
|
- 对外接口返回 `ResponseEntity<...>` 保持一致
|
||||||
|
|
||||||
|
### 6.4 异常与错误处理
|
||||||
|
|
||||||
|
- 与现有代码一致:业务错误返回 JSON(`message` 或 `error` 字段)
|
||||||
|
- 鉴权失败返回 401;参数错误返回 400;服务异常返回 500
|
||||||
|
- 记录日志时避免输出明文凭据(password/privateKey/passphrase/token)
|
||||||
|
- SFTP 相关改动需考虑并发安全:`ChannelSftp` 非线程安全
|
||||||
|
|
||||||
|
### 6.5 安全相关
|
||||||
|
|
||||||
|
- JWT 校验逻辑位于过滤器与握手流程,改动需覆盖 HTTP + WS 场景
|
||||||
|
- 允许跨域来源在 `SecurityConfig` 中集中维护
|
||||||
|
- 加密逻辑统一走 `EncryptionService`,禁止绕过直接落库明文
|
||||||
|
|
||||||
|
## 7) 前端风格(Vue 3 / TS / Tailwind)
|
||||||
|
|
||||||
|
### 7.1 导入与模块组织
|
||||||
|
|
||||||
|
- 使用 `<script setup lang="ts">`
|
||||||
|
- import 优先级参考现有文件:
|
||||||
|
- Vue 核心库
|
||||||
|
- 路由 / store
|
||||||
|
- 本地 API / type / 组件
|
||||||
|
- 图标或其他第三方
|
||||||
|
- 相对路径保持简洁,避免无必要跨层引用
|
||||||
|
|
||||||
|
### 7.2 格式与命名
|
||||||
|
|
||||||
|
- 2 空格缩进
|
||||||
|
- TypeScript 与 Vue 脚本保持“无分号 + 单引号”风格
|
||||||
|
- 组件文件名 `PascalCase.vue`
|
||||||
|
- store 命名:`useXxxStore`
|
||||||
|
- 方法变量 `camelCase`,常量 `UPPER_SNAKE_CASE`
|
||||||
|
|
||||||
|
### 7.3 类型与状态
|
||||||
|
|
||||||
|
- API 类型定义集中在 `frontend/src/api/*.ts`
|
||||||
|
- 对可空值显式标注(如 `Connection | null`)
|
||||||
|
- Pinia store 负责状态与数据同步,视图层尽量避免重复请求逻辑
|
||||||
|
- 新增接口先补类型,再写调用
|
||||||
|
|
||||||
|
### 7.4 UI 与交互
|
||||||
|
|
||||||
|
- 延续 Tailwind 工具类写法,不引入额外 CSS 框架
|
||||||
|
- 保持现有深色主题语言(`slate/cyan`)与可访问性属性(如 `aria-label`)
|
||||||
|
- 交互失败要有可感知反馈(至少日志或错误提示)
|
||||||
|
- 注意终端/SFTP页面的响应性能,避免不必要重渲染
|
||||||
|
|
||||||
|
## 8) 提交前自检清单
|
||||||
|
|
||||||
|
- 后端改动:至少运行 `mvn test`(若存在测试)或最小可行编译验证
|
||||||
|
- 前端改动:运行 `npm run build`(包含 `vue-tsc`)
|
||||||
|
- 手工验证登录流程、连接列表、终端或 SFTP 关键路径(按改动范围)
|
||||||
|
- 检查未提交敏感信息与本地配置
|
||||||
|
- 仅提交与需求直接相关的文件
|
||||||
|
|
||||||
|
## 9) 文档与规则文件检查结果
|
||||||
|
|
||||||
|
- `AGENTS.md`:本文件为新建(仓库根目录)
|
||||||
|
- Cursor 规则:未发现 `.cursor/rules/` 或 `.cursorrules`
|
||||||
|
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
||||||
|
|
||||||
|
若未来新增上述规则文件,agents 必须先读取并将其视为高优先级约束。
|
||||||
@@ -24,7 +24,7 @@ cd backend
|
|||||||
mvn spring-boot:run
|
mvn spring-boot:run
|
||||||
```
|
```
|
||||||
|
|
||||||
后端运行在 http://localhost:8080
|
后端运行在 http://localhost:48080
|
||||||
|
|
||||||
默认登录:`admin` / `admin123`
|
默认登录:`admin` / `admin123`
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,11 @@
|
|||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Fork with modern algorithm support (ed25519, current kex/ciphers); drop-in for com.jcraft.jsch -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.jcraft</groupId>
|
<groupId>com.github.mwiede</groupId>
|
||||||
<artifactId>jsch</artifactId>
|
<artifactId>jsch</artifactId>
|
||||||
<version>0.1.55</version>
|
<version>2.27.6</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173"));
|
config.setAllowedOrigins(Arrays.asList(
|
||||||
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||||
|
"http://localhost:48080", "http://127.0.0.1:48080"
|
||||||
|
));
|
||||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(Arrays.asList("*"));
|
config.setAllowedHeaders(Arrays.asList("*"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.sshmanager.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA 前端路由回退:未匹配到静态资源时返回 index.html,供 Vue Router history 模式使用。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SpaForwardConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
registry.addResourceHandler("/**")
|
||||||
|
.addResourceLocations("classpath:/static/")
|
||||||
|
.resourceChain(true)
|
||||||
|
.addResolver(new PathResourceResolver() {
|
||||||
|
@Override
|
||||||
|
protected Resource getResource(String path, Resource location) throws IOException {
|
||||||
|
Resource resource = location.createRelative(path);
|
||||||
|
if (resource.exists() && resource.isReadable()) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
return location.createRelative("index.html");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||||
.addInterceptors(terminalHandshakeInterceptor)
|
.addInterceptors(terminalHandshakeInterceptor)
|
||||||
.setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173");
|
.setAllowedOrigins(
|
||||||
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||||
|
"http://localhost:48080", "http://127.0.0.1:48080"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
package com.sshmanager.controller;
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.SftpException;
|
||||||
import com.sshmanager.dto.SftpFileInfo;
|
import com.sshmanager.dto.SftpFileInfo;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
import com.sshmanager.entity.User;
|
import com.sshmanager.entity.User;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
import com.sshmanager.service.SftpService;
|
import com.sshmanager.service.SftpService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/sftp")
|
@RequestMapping("/api/sftp")
|
||||||
public class SftpController {
|
public class SftpController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SftpController.class);
|
||||||
|
|
||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final SftpService sftpService;
|
private final SftpService sftpService;
|
||||||
|
|
||||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
/**
|
||||||
|
* JSch ChannelSftp is not thread-safe. If the frontend triggers concurrent requests (e.g. rapid ".." navigation),
|
||||||
|
* sharing one ChannelSftp can crash with internal stream exceptions. We serialize all SFTP ops per (user, connection).
|
||||||
|
*/
|
||||||
|
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public SftpController(ConnectionService connectionService,
|
public SftpController(ConnectionService connectionService,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
@@ -46,6 +58,28 @@ public class SftpController {
|
|||||||
return userId + ":" + connectionId;
|
return userId + ":" + connectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T> T withSessionLock(String key, Supplier<T> action) {
|
||||||
|
Object lock = sessionLocks.computeIfAbsent(key, k -> new Object());
|
||||||
|
synchronized (lock) {
|
||||||
|
return action.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T withTwoSessionLocks(String keyA, String keyB, Supplier<T> action) {
|
||||||
|
if (keyA.equals(keyB)) {
|
||||||
|
return withSessionLock(keyA, action);
|
||||||
|
}
|
||||||
|
String first = keyA.compareTo(keyB) < 0 ? keyA : keyB;
|
||||||
|
String second = keyA.compareTo(keyB) < 0 ? keyB : keyA;
|
||||||
|
Object firstLock = sessionLocks.computeIfAbsent(first, k -> new Object());
|
||||||
|
Object secondLock = sessionLocks.computeIfAbsent(second, k -> new Object());
|
||||||
|
synchronized (firstLock) {
|
||||||
|
synchronized (secondLock) {
|
||||||
|
return action.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
|
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
|
||||||
String key = sessionKey(userId, connectionId);
|
String key = sessionKey(userId, connectionId);
|
||||||
SftpService.SftpSession session = sessions.get(key);
|
SftpService.SftpSession session = sessions.get(key);
|
||||||
@@ -61,12 +95,15 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public ResponseEntity<List<SftpFileInfo>> list(
|
public ResponseEntity<?> list(
|
||||||
@RequestParam Long connectionId,
|
@RequestParam Long connectionId,
|
||||||
@RequestParam(required = false, defaultValue = ".") String path,
|
@RequestParam(required = false, defaultValue = ".") String path,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
||||||
List<SftpFileInfo> dtos = files.stream()
|
List<SftpFileInfo> dtos = files.stream()
|
||||||
@@ -74,8 +111,39 @@ public class SftpController {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return ResponseEntity.ok(dtos);
|
return ResponseEntity.ok(dtos);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).build();
|
// If the underlying SFTP channel got into a bad state, force reconnect on next request.
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
}
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
String errorMsg = toSftpErrorMessage(e, path, "list");
|
||||||
|
log.warn("SFTP list failed: connectionId={}, path={}, error={}", connectionId, path, errorMsg, e);
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", errorMsg);
|
||||||
|
return ResponseEntity.status(500).body(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||||
|
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||||
|
return e.getMessage();
|
||||||
|
}
|
||||||
|
// Unwrap nested RuntimeExceptions to find the underlying SftpException (if any).
|
||||||
|
Throwable cur = e;
|
||||||
|
for (int i = 0; i < 10 && cur != null; i++) {
|
||||||
|
if (cur instanceof SftpException) {
|
||||||
|
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||||
|
}
|
||||||
|
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
||||||
|
return cur.getMessage();
|
||||||
|
}
|
||||||
|
cur = cur.getCause();
|
||||||
|
}
|
||||||
|
return operation + " failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/pwd")
|
@GetMapping("/pwd")
|
||||||
@@ -84,30 +152,58 @@ public class SftpController {
|
|||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
String pwd = sftpService.pwd(session);
|
String pwd = sftpService.pwd(session);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("path", pwd);
|
result.put("path", pwd);
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).build();
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
||||||
|
return ResponseEntity.status(500).body(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/download")
|
@GetMapping("/download")
|
||||||
public ResponseEntity<byte[]> download(
|
public ResponseEntity<StreamingResponseBody> download(
|
||||||
@RequestParam Long connectionId,
|
@RequestParam Long connectionId,
|
||||||
@RequestParam String path,
|
@RequestParam String path,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
String key = sessionKey(userId, connectionId);
|
||||||
byte[] data = sftpService.download(session, path);
|
|
||||||
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
|
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
|
||||||
|
StreamingResponseBody stream = outputStream -> withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
|
sftpService.download(session, path, outputStream);
|
||||||
|
outputStream.flush();
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
.body(data);
|
.body(stream);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).build();
|
return ResponseEntity.status(500).build();
|
||||||
}
|
}
|
||||||
@@ -121,14 +217,27 @@ public class SftpController {
|
|||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||||
? "/" + file.getOriginalFilename()
|
? "/" + file.getOriginalFilename()
|
||||||
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
||||||
sftpService.upload(session, remotePath, file.getBytes());
|
try (java.io.InputStream in = file.getInputStream()) {
|
||||||
|
sftpService.upload(session, remotePath, in);
|
||||||
|
}
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Uploaded");
|
result.put("message", "Uploaded");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", e.getMessage());
|
||||||
@@ -144,11 +253,22 @@ public class SftpController {
|
|||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
sftpService.delete(session, path, directory);
|
sftpService.delete(session, path, directory);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Deleted");
|
result.put("message", "Deleted");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", e.getMessage());
|
||||||
@@ -163,11 +283,22 @@ public class SftpController {
|
|||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
sftpService.mkdir(session, path);
|
sftpService.mkdir(session, path);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Created");
|
result.put("message", "Created");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", e.getMessage());
|
||||||
@@ -183,11 +314,22 @@ public class SftpController {
|
|||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
String key = sessionKey(userId, connectionId);
|
||||||
|
return withSessionLock(key, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
sftpService.rename(session, oldPath, newPath);
|
sftpService.rename(session, oldPath, newPath);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Renamed");
|
result.put("message", "Renamed");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.disconnect();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", e.getMessage());
|
||||||
@@ -214,6 +356,10 @@ public class SftpController {
|
|||||||
err.put("error", "targetPath is required");
|
err.put("error", "targetPath is required");
|
||||||
return ResponseEntity.badRequest().body(err);
|
return ResponseEntity.badRequest().body(err);
|
||||||
}
|
}
|
||||||
|
String sourceKey = sessionKey(userId, sourceConnectionId);
|
||||||
|
String targetKey = sessionKey(userId, targetConnectionId);
|
||||||
|
withTwoSessionLocks(sourceKey, targetKey, () -> {
|
||||||
|
try {
|
||||||
SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId);
|
SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId);
|
||||||
SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId);
|
SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId);
|
||||||
if (sourceConnectionId.equals(targetConnectionId)) {
|
if (sourceConnectionId.equals(targetConnectionId)) {
|
||||||
@@ -221,6 +367,21 @@ public class SftpController {
|
|||||||
} else {
|
} else {
|
||||||
sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim());
|
sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim());
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
SftpService.SftpSession source = sessions.remove(sourceKey);
|
||||||
|
if (source != null) {
|
||||||
|
source.disconnect();
|
||||||
|
}
|
||||||
|
if (!sourceKey.equals(targetKey)) {
|
||||||
|
SftpService.SftpSession target = sessions.remove(targetKey);
|
||||||
|
if (target != null) {
|
||||||
|
target.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Transferred");
|
result.put("message", "Transferred");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
@@ -241,6 +402,7 @@ public class SftpController {
|
|||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.disconnect();
|
session.disconnect();
|
||||||
}
|
}
|
||||||
|
sessionLocks.remove(key);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Disconnected");
|
result.put("message", "Disconnected");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||||
return bearerToken.substring(7);
|
return bearerToken.substring(7);
|
||||||
}
|
}
|
||||||
|
// WebSocket handshake sends token as query param
|
||||||
|
if (request.getRequestURI() != null && request.getRequestURI().startsWith("/ws/")) {
|
||||||
|
String token = request.getParameter("token");
|
||||||
|
if (StringUtils.hasText(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,14 +48,15 @@ public class ConnectionService {
|
|||||||
conn.setUsername(request.getUsername());
|
conn.setUsername(request.getUsername());
|
||||||
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
|
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) {
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||||
} else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) {
|
conn.setEncryptedPrivateKey(null);
|
||||||
|
conn.setPassphrase(null);
|
||||||
|
} else {
|
||||||
|
conn.setEncryptedPassword(null);
|
||||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||||
if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) {
|
|
||||||
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
|
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
conn = connectionRepository.save(conn);
|
conn = connectionRepository.save(conn);
|
||||||
return ConnectionDto.fromEntity(conn);
|
return ConnectionDto.fromEntity(conn);
|
||||||
@@ -75,15 +76,20 @@ public class ConnectionService {
|
|||||||
if (request.getUsername() != null) conn.setUsername(request.getUsername());
|
if (request.getUsername() != null) conn.setUsername(request.getUsername());
|
||||||
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
|
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
|
||||||
|
|
||||||
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
if (request.getPassword() != null) {
|
if (request.getPassword() != null) {
|
||||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||||
}
|
}
|
||||||
|
conn.setEncryptedPrivateKey(null);
|
||||||
|
conn.setPassphrase(null);
|
||||||
|
} else {
|
||||||
if (request.getPrivateKey() != null) {
|
if (request.getPrivateKey() != null) {
|
||||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||||
}
|
}
|
||||||
if (request.getPassphrase() != null) {
|
if (request.getPassphrase() != null) {
|
||||||
conn.setPassphrase(request.getPassphrase().isEmpty() ? null :
|
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
|
||||||
encryptionService.encrypt(request.getPassphrase()));
|
}
|
||||||
|
conn.setEncryptedPassword(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.setUpdatedAt(Instant.now());
|
conn.setUpdatedAt(Instant.now());
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.sshmanager.service;
|
|||||||
import com.jcraft.jsch.ChannelSftp;
|
import com.jcraft.jsch.ChannelSftp;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.jcraft.jsch.SftpException;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.io.PipedInputStream;
|
import java.io.PipedInputStream;
|
||||||
import java.io.PipedOutputStream;
|
import java.io.PipedOutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -38,6 +38,8 @@ public class SftpService {
|
|||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
|
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||||
|
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||||
session.setPassword(password);
|
session.setPassword(password);
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,9 @@ public class SftpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||||
Vector<?> entries = sftpSession.getChannel().ls(path);
|
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
||||||
|
try {
|
||||||
|
Vector<?> entries = sftpSession.getChannel().ls(listPath);
|
||||||
List<FileInfo> result = new ArrayList<>();
|
List<FileInfo> result = new ArrayList<>();
|
||||||
for (Object obj : entries) {
|
for (Object obj : entries) {
|
||||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||||
@@ -105,16 +109,51 @@ public class SftpService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
} catch (SftpException e) {
|
||||||
|
String msg = formatSftpExceptionMessage(e, listPath, "list");
|
||||||
|
throw new RuntimeException(msg, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {
|
/**
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
* Build a user-visible message from JSch SftpException (getMessage() is often null).
|
||||||
|
*/
|
||||||
|
public static String formatSftpExceptionMessage(SftpException e, String path, String operation) {
|
||||||
|
int id = e.id;
|
||||||
|
String serverMsg = e.getMessage();
|
||||||
|
String reason = sftpErrorCodeToMessage(id);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(reason);
|
||||||
|
if (path != null && !path.isEmpty()) {
|
||||||
|
sb.append(": ").append(path);
|
||||||
|
}
|
||||||
|
if (serverMsg != null && !serverMsg.trim().isEmpty()) {
|
||||||
|
sb.append(" (").append(serverMsg).append(")");
|
||||||
|
} else {
|
||||||
|
sb.append(" [SFTP status ").append(id).append("]");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sftpErrorCodeToMessage(int id) {
|
||||||
|
switch (id) {
|
||||||
|
case 2: return "No such file or directory";
|
||||||
|
case 3: return "Permission denied";
|
||||||
|
case 4: return "Operation failed";
|
||||||
|
case 5: return "Bad message";
|
||||||
|
case 6: return "No connection";
|
||||||
|
case 7: return "Connection lost";
|
||||||
|
case 8: return "Operation not supported";
|
||||||
|
default: return "SFTP error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception {
|
||||||
sftpSession.getChannel().get(remotePath, out);
|
sftpSession.getChannel().get(remotePath, out);
|
||||||
return out.toByteArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception {
|
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
|
||||||
sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath);
|
sftpSession.getChannel().put(in, remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public class SshService {
|
|||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
|
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||||
|
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||||
session.setPassword(password);
|
session.setPassword(password);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ server:
|
|||||||
port: 48080
|
port: 48080
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
web:
|
||||||
|
resources:
|
||||||
|
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.ChannelSftp;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.SftpService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SftpControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionService connectionService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SftpService sftpService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private SftpController sftpController;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transferRemoteReturnsBadRequestWhenSourcePathMissing() {
|
||||||
|
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||||
|
1L,
|
||||||
|
" ",
|
||||||
|
2L,
|
||||||
|
"/tmp/target.txt",
|
||||||
|
authentication()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||||
|
assertEquals("sourcePath is required", response.getBody().get("error"));
|
||||||
|
verifyNoInteractions(sftpService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transferRemoteUsesRenameWhenSourceAndTargetConnectionAreSame() throws Exception {
|
||||||
|
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
|
||||||
|
SftpService.SftpSession session = connectedSession(true);
|
||||||
|
when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||||
|
3L,
|
||||||
|
"/src/file.txt",
|
||||||
|
3L,
|
||||||
|
"/dst/file.txt",
|
||||||
|
authentication()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
assertEquals("Transferred", response.getBody().get("message"));
|
||||||
|
verify(sftpService).rename(session, "/src/file.txt", "/dst/file.txt");
|
||||||
|
verify(sftpService, never()).transferRemote(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transferRemoteUsesCrossSessionTransferWhenConnectionsDiffer() throws Exception {
|
||||||
|
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
|
||||||
|
SftpService.SftpSession sourceSession = connectedSession(false);
|
||||||
|
SftpService.SftpSession targetSession = connectedSession(false);
|
||||||
|
when(sftpService.connect(any(Connection.class), any(), any(), any()))
|
||||||
|
.thenReturn(sourceSession)
|
||||||
|
.thenReturn(targetSession);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||||
|
10L,
|
||||||
|
"/src/file.txt",
|
||||||
|
20L,
|
||||||
|
"/dst/file.txt",
|
||||||
|
authentication()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
assertEquals("Transferred", response.getBody().get("message"));
|
||||||
|
verify(sftpService).transferRemote(sourceSession, "/src/file.txt", targetSession, "/dst/file.txt");
|
||||||
|
verify(sftpService, never()).rename(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transferRemoteReturnsServerErrorWhenTransferFails() throws Exception {
|
||||||
|
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
|
||||||
|
SftpService.SftpSession session = connectedSession(true);
|
||||||
|
when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session);
|
||||||
|
doThrow(new RuntimeException("boom")).when(sftpService).rename(any(), any(), any());
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||||
|
3L,
|
||||||
|
"/src/file.txt",
|
||||||
|
3L,
|
||||||
|
"/dst/file.txt",
|
||||||
|
authentication()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||||
|
assertTrue(response.getBody().get("error").contains("boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Authentication authentication() {
|
||||||
|
Authentication authentication = mock(Authentication.class);
|
||||||
|
when(authentication.getName()).thenReturn("alice");
|
||||||
|
return authentication;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SftpService.SftpSession connectedSession(boolean connected) {
|
||||||
|
Session session = mock(Session.class);
|
||||||
|
ChannelSftp channel = mock(ChannelSftp.class);
|
||||||
|
if (connected) {
|
||||||
|
when(channel.isConnected()).thenReturn(true);
|
||||||
|
}
|
||||||
|
return new SftpService.SftpSession(session, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||||
|
import com.sshmanager.dto.ConnectionDto;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.repository.ConnectionRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ConnectionServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionRepository connectionRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EncryptionService encryptionService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ConnectionService connectionService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(connectionRepository.save(any(Connection.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPasswordConnectionClearsPrivateKeyCredentials() {
|
||||||
|
ConnectionCreateRequest request = new ConnectionCreateRequest();
|
||||||
|
request.setName("prod");
|
||||||
|
request.setHost("127.0.0.1");
|
||||||
|
request.setUsername("root");
|
||||||
|
request.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
request.setPassword("secret");
|
||||||
|
request.setPrivateKey("unused-key");
|
||||||
|
request.setPassphrase("unused-passphrase");
|
||||||
|
|
||||||
|
when(encryptionService.encrypt("secret")).thenReturn("enc-secret");
|
||||||
|
|
||||||
|
ConnectionDto result = connectionService.create(request, 1L);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
|
||||||
|
verify(connectionRepository).save(captor.capture());
|
||||||
|
Connection saved = captor.getValue();
|
||||||
|
|
||||||
|
assertEquals(Connection.AuthType.PASSWORD, saved.getAuthType());
|
||||||
|
assertEquals("enc-secret", saved.getEncryptedPassword());
|
||||||
|
assertNull(saved.getEncryptedPrivateKey());
|
||||||
|
assertNull(saved.getPassphrase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPrivateKeyConnectionClearsPasswordCredential() {
|
||||||
|
ConnectionCreateRequest request = new ConnectionCreateRequest();
|
||||||
|
request.setName("prod");
|
||||||
|
request.setHost("127.0.0.1");
|
||||||
|
request.setUsername("root");
|
||||||
|
request.setAuthType(Connection.AuthType.PRIVATE_KEY);
|
||||||
|
request.setPassword("unused-password");
|
||||||
|
request.setPrivateKey("private-key");
|
||||||
|
request.setPassphrase("passphrase");
|
||||||
|
|
||||||
|
when(encryptionService.encrypt("private-key")).thenReturn("enc-key");
|
||||||
|
when(encryptionService.encrypt("passphrase")).thenReturn("enc-passphrase");
|
||||||
|
|
||||||
|
ConnectionDto result = connectionService.create(request, 1L);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
|
||||||
|
verify(connectionRepository).save(captor.capture());
|
||||||
|
Connection saved = captor.getValue();
|
||||||
|
|
||||||
|
assertEquals(Connection.AuthType.PRIVATE_KEY, saved.getAuthType());
|
||||||
|
assertEquals("enc-key", saved.getEncryptedPrivateKey());
|
||||||
|
assertEquals("enc-passphrase", saved.getPassphrase());
|
||||||
|
assertNull(saved.getEncryptedPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateSwitchToPrivateKeyClearsPasswordCredential() {
|
||||||
|
Connection existing = new Connection();
|
||||||
|
existing.setId(10L);
|
||||||
|
existing.setUserId(1L);
|
||||||
|
existing.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
existing.setEncryptedPassword("old-password");
|
||||||
|
|
||||||
|
ConnectionCreateRequest request = new ConnectionCreateRequest();
|
||||||
|
request.setAuthType(Connection.AuthType.PRIVATE_KEY);
|
||||||
|
request.setPrivateKey("new-key");
|
||||||
|
request.setPassphrase("new-passphrase");
|
||||||
|
|
||||||
|
when(connectionRepository.findById(10L)).thenReturn(Optional.of(existing));
|
||||||
|
when(encryptionService.encrypt("new-key")).thenReturn("enc-new-key");
|
||||||
|
when(encryptionService.encrypt("new-passphrase")).thenReturn("enc-new-passphrase");
|
||||||
|
|
||||||
|
ConnectionDto result = connectionService.update(10L, request, 1L);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
|
||||||
|
verify(connectionRepository).save(captor.capture());
|
||||||
|
Connection saved = captor.getValue();
|
||||||
|
|
||||||
|
assertEquals(Connection.AuthType.PRIVATE_KEY, saved.getAuthType());
|
||||||
|
assertEquals("enc-new-key", saved.getEncryptedPrivateKey());
|
||||||
|
assertEquals("enc-new-passphrase", saved.getPassphrase());
|
||||||
|
assertNull(saved.getEncryptedPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateSwitchToPasswordClearsPrivateKeyCredentials() {
|
||||||
|
Connection existing = new Connection();
|
||||||
|
existing.setId(20L);
|
||||||
|
existing.setUserId(1L);
|
||||||
|
existing.setAuthType(Connection.AuthType.PRIVATE_KEY);
|
||||||
|
existing.setEncryptedPrivateKey("old-key");
|
||||||
|
existing.setPassphrase("old-passphrase");
|
||||||
|
|
||||||
|
ConnectionCreateRequest request = new ConnectionCreateRequest();
|
||||||
|
request.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
request.setPassword("new-password");
|
||||||
|
|
||||||
|
when(connectionRepository.findById(20L)).thenReturn(Optional.of(existing));
|
||||||
|
when(encryptionService.encrypt("new-password")).thenReturn("enc-new-password");
|
||||||
|
|
||||||
|
ConnectionDto result = connectionService.update(20L, request, 1L);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
|
||||||
|
verify(connectionRepository).save(captor.capture());
|
||||||
|
Connection saved = captor.getValue();
|
||||||
|
|
||||||
|
assertEquals(Connection.AuthType.PASSWORD, saved.getAuthType());
|
||||||
|
assertEquals("enc-new-password", saved.getEncryptedPassword());
|
||||||
|
assertNull(saved.getEncryptedPrivateKey());
|
||||||
|
assertNull(saved.getPassphrase());
|
||||||
|
}
|
||||||
|
}
|
||||||
6
docker/.npmrc
Normal file
6
docker/.npmrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 使用国内 npm 镜像(npmmirror 淘宝镜像)
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
sass_binary_site=https://npmmirror.com/mirrors/node-sass
|
||||||
|
phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
chromedriver_cdnurl=https://npmmirror.com/mirrors/chromedriver
|
||||||
48
docker/Dockerfile
Normal file
48
docker/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# ========== 阶段一:前端构建(国内 npm 源) ==========
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
|
||||||
|
# 使用国内 npm 镜像(npmmirror)
|
||||||
|
COPY docker/.npmrc /root/.npmrc
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci --prefer-offline --no-audit
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ========== 阶段二:后端构建(国内 Maven 源) ==========
|
||||||
|
FROM maven:3.9-eclipse-temurin-8-alpine AS backend
|
||||||
|
|
||||||
|
COPY docker/maven-settings.xml /root/.m2/settings.xml
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 先复制 pom,利用层缓存
|
||||||
|
COPY backend/pom.xml ./
|
||||||
|
RUN mvn dependency:go-offline -B -q
|
||||||
|
|
||||||
|
# 复制后端源码
|
||||||
|
COPY backend/src ./src
|
||||||
|
|
||||||
|
# 将前端打包结果放入 Spring Boot 静态资源目录
|
||||||
|
COPY --from=frontend /app/dist ./src/main/resources/static
|
||||||
|
|
||||||
|
RUN mvn package -DskipTests -B -q
|
||||||
|
|
||||||
|
# ========== 阶段三:运行(单容器,仅 Java) ==========
|
||||||
|
FROM eclipse-temurin:8-jre-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache libgcc tzdata \
|
||||||
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=backend /build/target/*.jar app.jar
|
||||||
|
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
RUN mkdir -p ${DATA_DIR}
|
||||||
|
|
||||||
|
EXPOSE 48080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
35
docker/README.md
Normal file
35
docker/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Docker 单容器部署
|
||||||
|
|
||||||
|
前端打包后放入 Spring Boot `static`,与 Java 一起在同一个容器内启动,不使用 Nginx。
|
||||||
|
|
||||||
|
## 国内源
|
||||||
|
|
||||||
|
- **npm**:`docker/.npmrc` 使用 npmmirror(淘宝镜像)
|
||||||
|
- **Maven**:`docker/maven-settings.xml` 使用阿里云仓库
|
||||||
|
|
||||||
|
## 构建与运行
|
||||||
|
|
||||||
|
在**项目根目录**执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
|
||||||
|
# 前台运行
|
||||||
|
docker compose -f docker/docker-compose.yml up
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:http://localhost:48080
|
||||||
|
|
||||||
|
## 环境变量(可选)
|
||||||
|
|
||||||
|
- `SSHMANAGER_ENCRYPTION_KEY`:连接密码加密密钥(生产务必修改)
|
||||||
|
- `SSHMANAGER_JWT_SECRET`:JWT 密钥(生产务必修改)
|
||||||
|
- `TZ`:时区,默认 `Asia/Shanghai`
|
||||||
|
|
||||||
|
## 数据持久化
|
||||||
|
|
||||||
|
H2 数据目录通过 volume `app-data` 挂载到 `/app/data`,重启容器数据保留。
|
||||||
33
docker/backend.Dockerfile
Normal file
33
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Backend: Maven 使用阿里云镜像,多阶段构建
|
||||||
|
FROM maven:3.9-eclipse-temurin-8-alpine AS builder
|
||||||
|
|
||||||
|
# 使用国内 Maven 配置(阿里云)
|
||||||
|
COPY docker/maven-settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 先复制 pom,利用 Docker 层缓存
|
||||||
|
COPY backend/pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -B -q
|
||||||
|
|
||||||
|
COPY backend/src ./src
|
||||||
|
RUN mvn package -DskipTests -B -q
|
||||||
|
|
||||||
|
# 运行阶段
|
||||||
|
FROM eclipse-temurin:8-jre-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache libgcc tzdata \
|
||||||
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/*.jar app.jar
|
||||||
|
|
||||||
|
# 数据目录(H2 数据库文件)
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
RUN mkdir -p ${DATA_DIR}
|
||||||
|
|
||||||
|
EXPOSE 48080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
24
docker/docker-compose.yml
Normal file
24
docker/docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 单容器运行:前端已打包进 JAR,由 Spring Boot 统一提供静态资源与 API
|
||||||
|
# 构建:在项目根目录执行 docker compose -f docker/docker-compose.yml build
|
||||||
|
# 运行:docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
image: ssh-manager:latest
|
||||||
|
container_name: ssh-manager
|
||||||
|
ports:
|
||||||
|
- "48080:48080"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
# 生产环境建议设置并挂载密钥
|
||||||
|
# - SSHMANAGER_ENCRYPTION_KEY=...
|
||||||
|
# - SSHMANAGER_JWT_SECRET=...
|
||||||
|
volumes:
|
||||||
|
- app-data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
61
docker/maven-settings.xml
Normal file
61
docker/maven-settings.xml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>aliyun-central</id>
|
||||||
|
<name>Aliyun Maven Central</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/central</url>
|
||||||
|
<mirrorOf>central</mirrorOf>
|
||||||
|
</mirror>
|
||||||
|
<mirror>
|
||||||
|
<id>aliyun-public</id>
|
||||||
|
<name>Aliyun Public</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>aliyun</id>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>central</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/central</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>spring</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/spring</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>spring-plugin</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/spring-plugin</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<pluginRepositories>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>central</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/central</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>spring-plugin</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/spring-plugin</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
</pluginRepositories>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
<activeProfiles>
|
||||||
|
<activeProfile>aliyun</activeProfile>
|
||||||
|
</activeProfiles>
|
||||||
|
</settings>
|
||||||
15
docker/start.sh
Normal file
15
docker/start.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 脚本所在目录为 docker/,项目根目录为其上级
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo ">>> 项目根目录: $ROOT"
|
||||||
|
echo ">>> 构建并启动..."
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> 已启动。访问: http://localhost:48080"
|
||||||
|
echo ">>> 查看日志: docker compose -f docker/docker-compose.yml logs -f"
|
||||||
216
frontend/package-lock.json
generated
216
frontend/package-lock.json
generated
@@ -637,9 +637,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -651,9 +651,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -665,9 +665,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -679,9 +679,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -693,9 +693,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -707,9 +707,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -721,9 +721,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -735,9 +735,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -749,9 +749,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -763,9 +763,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -777,9 +777,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -791,9 +791,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -805,9 +805,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -819,9 +819,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -833,9 +833,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -847,9 +847,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -861,9 +861,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -875,9 +875,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -889,9 +889,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -903,9 +903,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -917,9 +917,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -931,9 +931,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -945,9 +945,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -959,9 +959,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -973,9 +973,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1402,13 +1402,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2744,9 +2744,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2760,31 +2760,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ function initPath() {
|
|||||||
currentPath.value = p || '.'
|
currentPath.value = p || '.'
|
||||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
loadPath()
|
loadPath()
|
||||||
}).catch(() => {
|
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||||
|
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||||||
currentPath.value = '.'
|
currentPath.value = '.'
|
||||||
pathParts.value = []
|
pathParts.value = []
|
||||||
loadPath()
|
loadPath()
|
||||||
@@ -76,8 +77,8 @@ function loadPath() {
|
|||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: { response?: { data?: { error?: string } } }) => {
|
||||||
error.value = '获取文件列表失败'
|
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -147,7 +148,9 @@ async function handleFileSelect(e: Event) {
|
|||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < selected.length; i++) {
|
for (let i = 0; i < selected.length; i++) {
|
||||||
await sftpApi.uploadFile(connectionId.value, path, selected[i])
|
const file = selected[i]
|
||||||
|
if (!file) continue
|
||||||
|
await sftpApi.uploadFile(connectionId.value, path, file)
|
||||||
}
|
}
|
||||||
loadPath()
|
loadPath()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user