Compare commits

...

10 Commits

Author SHA1 Message Date
liumangmang
a61a88f36b Implement session locking in SftpController to ensure thread safety during concurrent SFTP operations. Introduce a method to handle session locks and improve error handling by forcing reconnections on exceptions. This change addresses potential issues with shared ChannelSftp instances in concurrent requests. 2026-02-04 15:03:37 +08:00
liumangmang
e792fb919d Fix SftpService to access SftpException ID directly via the 'id' field for improved error message formatting. 2026-02-04 14:55:41 +08:00
liumangmang
7f57d69756 Enhance SFTP error handling in SftpController and SftpService by introducing a method to format SftpException messages. Improve listFiles method to handle empty paths and provide clearer error messages in response to exceptions. 2026-02-04 14:43:54 +08:00
liumangmang
a1b8a4af8c Update Dockerfiles to include libgcc in the Alpine image for improved compatibility with Java applications. 2026-02-04 12:31:48 +08:00
liumangmang
ea38d1c026 Add DH-based key exchange algorithms in SftpService and SshService to ensure compatibility with Java 8 minimal JRE 2026-02-04 12:03:29 +08:00
liumangmang
a67562bfea Update jsch dependency to a modern version with enhanced algorithm support and change groupId for compatibility. 2026-02-04 11:57:36 +08:00
liumangmang
b82ea1919e Enhance CORS configuration and WebSocket origin settings to include additional localhost ports. Improve error handling in SftpController and SftpView for better debugging and user feedback. 2026-02-04 11:47:08 +08:00
liumangmang
1aefc14e42 Fix file upload handling in SftpView by adding a check for undefined files before uploading. 2026-02-04 11:40:24 +08:00
liumangmang
669dc11064 Format and clean up the start.sh script for improved readability and consistency. 2026-02-04 11:30:55 +08:00
liumangmang
4558ef20c0 Update application.yml to disable resource mapping for improved SPA handling 2026-02-04 11:16:01 +08:00
18 changed files with 508 additions and 65 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Shell scripts: always LF so ./start.sh works on Linux/WSL
*.sh text eol=lf

View File

@@ -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>

View File

@@ -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);

View File

@@ -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");
}
});
}
}

View File

@@ -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"
);
} }
} }

View File

@@ -1,11 +1,14 @@
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;
@@ -17,17 +20,25 @@ 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 +57,13 @@ 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 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,36 +79,84 @@ 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);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId); String key = sessionKey(userId, connectionId);
List<SftpService.FileInfo> files = sftpService.listFiles(session, path); return withSessionLock(key, () -> {
List<SftpFileInfo> dtos = files.stream() try {
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime)) SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
.collect(Collectors.toList()); List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
return ResponseEntity.ok(dtos); List<SftpFileInfo> dtos = files.stream()
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
// 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) { } catch (Exception e) {
return ResponseEntity.status(500).build(); 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")
public ResponseEntity<Map<String, String>> pwd( public ResponseEntity<Map<String, String>> pwd(
@RequestParam Long connectionId, @RequestParam Long connectionId,
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);
String pwd = sftpService.pwd(session); return withSessionLock(key, () -> {
Map<String, String> result = new HashMap<>(); try {
result.put("path", pwd); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
return ResponseEntity.ok(result); String pwd = sftpService.pwd(session);
Map<String, String> result = new HashMap<>();
result.put("path", pwd);
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) {
return ResponseEntity.status(500).build(); 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);
} }
} }
@@ -101,13 +167,24 @@ public class SftpController {
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); return withSessionLock(key, () -> {
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; try {
return ResponseEntity.ok() SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") byte[] data = sftpService.download(session, path);
.contentType(MediaType.APPLICATION_OCTET_STREAM) String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
.body(data); return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(data);
} catch (Exception e) {
SftpService.SftpSession existing = sessions.remove(key);
if (existing != null) {
existing.disconnect();
}
throw new RuntimeException(e);
}
});
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).build(); return ResponseEntity.status(500).build();
} }
@@ -121,14 +198,25 @@ public class SftpController {
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);
String remotePath = (path == null || path.isEmpty() || path.equals("/")) return withSessionLock(key, () -> {
? "/" + file.getOriginalFilename() try {
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename()); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
sftpService.upload(session, remotePath, file.getBytes()); String remotePath = (path == null || path.isEmpty() || path.equals("/"))
Map<String, String> result = new HashMap<>(); ? "/" + file.getOriginalFilename()
result.put("message", "Uploaded"); : (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
return ResponseEntity.ok(result); sftpService.upload(session, remotePath, file.getBytes());
Map<String, String> result = new HashMap<>();
result.put("message", "Uploaded");
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 +232,22 @@ public class SftpController {
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);
sftpService.delete(session, path, directory); return withSessionLock(key, () -> {
Map<String, String> result = new HashMap<>(); try {
result.put("message", "Deleted"); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
return ResponseEntity.ok(result); sftpService.delete(session, path, directory);
Map<String, String> result = new HashMap<>();
result.put("message", "Deleted");
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 +262,22 @@ public class SftpController {
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);
sftpService.mkdir(session, path); return withSessionLock(key, () -> {
Map<String, String> result = new HashMap<>(); try {
result.put("message", "Created"); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
return ResponseEntity.ok(result); sftpService.mkdir(session, path);
Map<String, String> result = new HashMap<>();
result.put("message", "Created");
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 +293,22 @@ public class SftpController {
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);
sftpService.rename(session, oldPath, newPath); return withSessionLock(key, () -> {
Map<String, String> result = new HashMap<>(); try {
result.put("message", "Renamed"); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
return ResponseEntity.ok(result); sftpService.rename(session, oldPath, newPath);
Map<String, String> result = new HashMap<>();
result.put("message", "Renamed");
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());
@@ -241,6 +362,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);

View File

@@ -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;
} }
} }

View File

@@ -3,6 +3,7 @@ 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;
@@ -38,6 +39,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,20 +94,59 @@ 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();
List<FileInfo> result = new ArrayList<>(); try {
for (Object obj : entries) { Vector<?> entries = sftpSession.getChannel().ls(listPath);
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; List<FileInfo> result = new ArrayList<>();
String name = entry.getFilename(); for (Object obj : entries) {
if (".".equals(name) || "..".equals(name)) continue; ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
result.add(new FileInfo( String name = entry.getFilename();
name, if (".".equals(name) || "..".equals(name)) continue;
entry.getAttrs().isDir(), result.add(new FileInfo(
entry.getAttrs().getSize(), name,
entry.getAttrs().getMTime() * 1000L entry.getAttrs().isDir(),
)); entry.getAttrs().getSize(),
entry.getAttrs().getMTime() * 1000L
));
}
return result;
} catch (SftpException e) {
String msg = formatSftpExceptionMessage(e, listPath, "list");
throw new RuntimeException(msg, e);
}
}
/**
* 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";
} }
return result;
} }
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception { public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {

View File

@@ -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);

View File

@@ -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

6
docker/.npmrc Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View File

@@ -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 {