diff --git a/.gitignore b/.gitignore index 7f73ba8..0082bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,17 @@ -# Backend -backend/target/ -backend/data/*.db -backend/data/*.mv.db - -# Frontend -frontend/node_modules/ -frontend/dist/ - -# Logs & IDE -*.log -.idea -.DS_Store -*.local - -# Keep frontend .gitignore for frontend-specific rules -!frontend/.gitignore +# Backend +backend/target/ +backend/data/*.db +backend/data/*.mv.db + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# Logs & IDE +*.log +.idea +.DS_Store +*.local + +# Keep frontend .gitignore for frontend-specific rules +!frontend/.gitignore diff --git a/README.md b/README.md index dde60aa..1911ca5 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,91 @@ -# SSH 管理器 - -基于 Web 的 SSH 连接管理工具,支持终端与 SFTP 文件传输。技术栈:Vue 3、Spring Boot(JDK 8)、JSch、xterm.js。 - -## 功能 - -- **认证**:本地用户登录(JWT) -- **连接管理**:SSH 连接的增删改查(密码或私钥) -- **Web 终端**:通过 WebSocket + xterm.js 实现实时 SSH 终端 -- **SFTP**:文件浏览,支持上传、下载、删除、创建目录 - -## 环境要求 - -- JDK 8+ -- Node.js 18+ -- Maven 3.6+ - -## 快速开始 - -### 后端 - -```bash -cd backend -mvn spring-boot:run -``` - -后端运行在 http://localhost:8080 - -默认登录:`admin` / `admin123` - -### 前端 - -```bash -cd frontend -npm install -npm run dev -``` - -前端运行在 http://localhost:5173(API 与 WebSocket 会代理到后端) - -### 生产构建 - -```bash -# 后端 -cd backend && mvn package - -# 前端 -cd frontend && npm run build -``` - -将 `frontend/dist` 目录内容复制到后端的静态资源目录,或单独部署前端。 - -## 项目结构 - -``` -ssh-manager/ -├── backend/ # Spring Boot(JDK 8) -│ └── src/main/java/com/sshmanager/ -│ ├── config/ # 安全、WebSocket、CORS -│ ├── controller/ -│ ├── service/ -│ ├── entity/ -│ └── repository/ -├── frontend/ # Vue 3 + Vite + Tailwind -│ └── src/ -│ ├── views/ -│ ├── components/ -│ ├── stores/ -│ └── api/ -└── design-system/ # UI/UX 规范 -``` - -## 配置 - -### 后端(application.yml) - -- `sshmanager.encryption-key`:用于加密连接密码的 Base64 32 字节密钥 -- `sshmanager.jwt-secret`:JWT 签名密钥 -- `spring.datasource.url`:H2 数据库路径(默认:`./data/sshmanager`) - -### 环境变量 - -- `SSHMANAGER_ENCRYPTION_KEY`:覆盖加密密钥 -- `SSHMANAGER_JWT_SECRET`:覆盖 JWT 密钥 - -## 安全说明 - -- 连接密码与私钥均以 AES-256-GCM 加密存储 -- 所有 API 接口需 JWT 认证 -- WebSocket 连接在握手时校验 JWT -- CORS 仅允许前端来源 +# SSH 管理器 + +基于 Web 的 SSH 连接管理工具,支持终端与 SFTP 文件传输。技术栈:Vue 3、Spring Boot(JDK 8)、JSch、xterm.js。 + +## 功能 + +- **认证**:本地用户登录(JWT) +- **连接管理**:SSH 连接的增删改查(密码或私钥) +- **Web 终端**:通过 WebSocket + xterm.js 实现实时 SSH 终端 +- **SFTP**:文件浏览,支持上传、下载、删除、创建目录 + +## 环境要求 + +- JDK 8+ +- Node.js 18+ +- Maven 3.6+ + +## 快速开始 + +### 后端 + +```bash +cd backend +mvn spring-boot:run +``` + +后端运行在 http://localhost:8080 + +默认登录:`admin` / `admin123` + +### 前端 + +```bash +cd frontend +npm install +npm run dev +``` + +前端运行在 http://localhost:5173(API 与 WebSocket 会代理到后端) + +### 生产构建 + +```bash +# 后端 +cd backend && mvn package + +# 前端 +cd frontend && npm run build +``` + +将 `frontend/dist` 目录内容复制到后端的静态资源目录,或单独部署前端。 + +## 项目结构 + +``` +ssh-manager/ +├── backend/ # Spring Boot(JDK 8) +│ └── src/main/java/com/sshmanager/ +│ ├── config/ # 安全、WebSocket、CORS +│ ├── controller/ +│ ├── service/ +│ ├── entity/ +│ └── repository/ +├── frontend/ # Vue 3 + Vite + Tailwind +│ └── src/ +│ ├── views/ +│ ├── components/ +│ ├── stores/ +│ └── api/ +└── design-system/ # UI/UX 规范 +``` + +## 配置 + +### 后端(application.yml) + +- `sshmanager.encryption-key`:用于加密连接密码的 Base64 32 字节密钥 +- `sshmanager.jwt-secret`:JWT 签名密钥 +- `spring.datasource.url`:H2 数据库路径(默认:`./data/sshmanager`) + +### 环境变量 + +- `SSHMANAGER_ENCRYPTION_KEY`:覆盖加密密钥 +- `SSHMANAGER_JWT_SECRET`:覆盖 JWT 密钥 + +## 安全说明 + +- 连接密码与私钥均以 AES-256-GCM 加密存储 +- 所有 API 接口需 JWT 认证 +- WebSocket 连接在握手时校验 JWT +- CORS 仅允许前端来源 diff --git a/backend/pom.xml b/backend/pom.xml index d02c0b6..b1fead0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -1,92 +1,92 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 2.7.18 - - - - com.sshmanager - ssh-manager-backend - 1.0.0 - ssh-manager-backend - Web SSH Manager Backend - - - 1.8 - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-websocket - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-data-jpa - - - com.h2database - h2 - - - com.jcraft - jsch - 0.1.55 - - - io.jsonwebtoken - jjwt-api - 0.11.5 - - - io.jsonwebtoken - jjwt-impl - 0.11.5 - runtime - - - io.jsonwebtoken - jjwt-jackson - 0.11.5 - runtime - - - org.projectlombok - lombok - true - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.sshmanager + ssh-manager-backend + 1.0.0 + ssh-manager-backend + Web SSH Manager Backend + + + 1.8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + + + com.jcraft + jsch + 0.1.55 + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/sshmanager/SshManagerApplication.java b/backend/src/main/java/com/sshmanager/SshManagerApplication.java index 1ab7a86..3f97a8b 100644 --- a/backend/src/main/java/com/sshmanager/SshManagerApplication.java +++ b/backend/src/main/java/com/sshmanager/SshManagerApplication.java @@ -1,12 +1,12 @@ -package com.sshmanager; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class SshManagerApplication { - - public static void main(String[] args) { - SpringApplication.run(SshManagerApplication.class, args); - } -} +package com.sshmanager; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SshManagerApplication { + + public static void main(String[] args) { + SpringApplication.run(SshManagerApplication.class, args); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/DataInitializer.java b/backend/src/main/java/com/sshmanager/config/DataInitializer.java index 47cc851..144b9cb 100644 --- a/backend/src/main/java/com/sshmanager/config/DataInitializer.java +++ b/backend/src/main/java/com/sshmanager/config/DataInitializer.java @@ -1,30 +1,30 @@ -package com.sshmanager.config; - -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import org.springframework.boot.CommandLineRunner; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -@Component -public class DataInitializer implements CommandLineRunner { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - public void run(String... args) { - if (userRepository.count() == 0) { - User admin = new User(); - admin.setUsername("admin"); - admin.setPasswordHash(passwordEncoder.encode("admin123")); - admin.setDisplayName("Administrator"); - userRepository.save(admin); - } - } -} +package com.sshmanager.config; + +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class DataInitializer implements CommandLineRunner { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public void run(String... args) { + if (userRepository.count() == 0) { + User admin = new User(); + admin.setUsername("admin"); + admin.setPasswordHash(passwordEncoder.encode("admin123")); + admin.setDisplayName("Administrator"); + userRepository.save(admin); + } + } +} diff --git a/backend/src/main/java/com/sshmanager/config/SecurityBeanConfig.java b/backend/src/main/java/com/sshmanager/config/SecurityBeanConfig.java index fa98737..1b94e22 100644 --- a/backend/src/main/java/com/sshmanager/config/SecurityBeanConfig.java +++ b/backend/src/main/java/com/sshmanager/config/SecurityBeanConfig.java @@ -1,15 +1,15 @@ -package com.sshmanager.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; - -@Configuration -public class SecurityBeanConfig { - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { - return config.getAuthenticationManager(); - } -} +package com.sshmanager.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; + +@Configuration +public class SecurityBeanConfig { + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java index 7333226..48234dd 100644 --- a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java +++ b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java @@ -1,74 +1,74 @@ -package com.sshmanager.config; - -import com.sshmanager.security.JwtAuthenticationFilter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - private final JwtAuthenticationFilter jwtAuthenticationFilter; - - @Autowired(required = false) - private SecurityExceptionHandler securityExceptionHandler; - - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .cors().and() - .csrf().disable() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .authorizeRequests() - .antMatchers("/api/auth/**").permitAll() - .antMatchers("/ws/**").authenticated() - .antMatchers("/api/**").authenticated() - .anyRequest().permitAll() - .and() - .exceptionHandling(e -> { - if (securityExceptionHandler != null) { - e.authenticationEntryPoint(securityExceptionHandler); - } - }) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173")); - config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); - config.setAllowedHeaders(Arrays.asList("*")); - config.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } -} +package com.sshmanager.config; + +import com.sshmanager.security.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Autowired(required = false) + private SecurityExceptionHandler securityExceptionHandler; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors().and() + .csrf().disable() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers("/api/auth/**").permitAll() + .antMatchers("/ws/**").authenticated() + .antMatchers("/api/**").authenticated() + .anyRequest().permitAll() + .and() + .exceptionHandling(e -> { + if (securityExceptionHandler != null) { + e.authenticationEntryPoint(securityExceptionHandler); + } + }) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(Arrays.asList("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/com/sshmanager/config/SecurityExceptionHandler.java b/backend/src/main/java/com/sshmanager/config/SecurityExceptionHandler.java index adbf39e..8a8c836 100644 --- a/backend/src/main/java/com/sshmanager/config/SecurityExceptionHandler.java +++ b/backend/src/main/java/com/sshmanager/config/SecurityExceptionHandler.java @@ -1,30 +1,30 @@ -package com.sshmanager.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.http.MediaType; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -@Component -public class SecurityExceptionHandler implements AuthenticationEntryPoint { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - - Map body = new HashMap<>(); - body.put("message", "Unauthorized"); - objectMapper.writeValue(response.getOutputStream(), body); - } -} +package com.sshmanager.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +public class SecurityExceptionHandler implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Map body = new HashMap<>(); + body.put("message", "Unauthorized"); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/TerminalHandshakeInterceptor.java b/backend/src/main/java/com/sshmanager/config/TerminalHandshakeInterceptor.java index cbf2099..fdf9363 100644 --- a/backend/src/main/java/com/sshmanager/config/TerminalHandshakeInterceptor.java +++ b/backend/src/main/java/com/sshmanager/config/TerminalHandshakeInterceptor.java @@ -1,56 +1,56 @@ -package com.sshmanager.config; - -import com.sshmanager.security.JwtTokenProvider; -import org.springframework.http.server.ServerHttpRequest; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.WebSocketHandler; -import org.springframework.web.socket.server.HandshakeInterceptor; - -import javax.servlet.http.HttpServletRequest; -import java.util.Map; - -@Component -public class TerminalHandshakeInterceptor implements HandshakeInterceptor { - - private final JwtTokenProvider jwtTokenProvider; - - public TerminalHandshakeInterceptor(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } - - @Override - public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, - WebSocketHandler wsHandler, Map attributes) throws Exception { - if (!(request instanceof ServletServerHttpRequest)) { - return false; - } - HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); - - String token = servletRequest.getParameter("token"); - String connectionIdStr = servletRequest.getParameter("connectionId"); - - if (token == null || token.isEmpty() || connectionIdStr == null || connectionIdStr.isEmpty()) { - return false; - } - if (!jwtTokenProvider.validateToken(token)) { - return false; - } - - try { - long connectionId = Long.parseLong(connectionIdStr); - String username = jwtTokenProvider.getUsernameFromToken(token); - attributes.put("connectionId", connectionId); - attributes.put("username", username); - return true; - } catch (NumberFormatException e) { - return false; - } - } - - @Override - public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, - WebSocketHandler wsHandler, Exception exception) { - } -} +package com.sshmanager.config; + +import com.sshmanager.security.JwtTokenProvider; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +@Component +public class TerminalHandshakeInterceptor implements HandshakeInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + public TerminalHandshakeInterceptor(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) throws Exception { + if (!(request instanceof ServletServerHttpRequest)) { + return false; + } + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + + String token = servletRequest.getParameter("token"); + String connectionIdStr = servletRequest.getParameter("connectionId"); + + if (token == null || token.isEmpty() || connectionIdStr == null || connectionIdStr.isEmpty()) { + return false; + } + if (!jwtTokenProvider.validateToken(token)) { + return false; + } + + try { + long connectionId = Long.parseLong(connectionIdStr); + String username = jwtTokenProvider.getUsernameFromToken(token); + attributes.put("connectionId", connectionId); + attributes.put("username", username); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + } +} diff --git a/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java b/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java index 59b3991..96211ad 100644 --- a/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java +++ b/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java @@ -1,28 +1,28 @@ -package com.sshmanager.config; - -import com.sshmanager.controller.TerminalWebSocketHandler; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - - private final TerminalWebSocketHandler terminalWebSocketHandler; - private final TerminalHandshakeInterceptor terminalHandshakeInterceptor; - - public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler, - TerminalHandshakeInterceptor terminalHandshakeInterceptor) { - this.terminalWebSocketHandler = terminalWebSocketHandler; - this.terminalHandshakeInterceptor = terminalHandshakeInterceptor; - } - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(terminalWebSocketHandler, "/ws/terminal") - .addInterceptors(terminalHandshakeInterceptor) - .setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173"); - } -} +package com.sshmanager.config; + +import com.sshmanager.controller.TerminalWebSocketHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + private final TerminalWebSocketHandler terminalWebSocketHandler; + private final TerminalHandshakeInterceptor terminalHandshakeInterceptor; + + public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler, + TerminalHandshakeInterceptor terminalHandshakeInterceptor) { + this.terminalWebSocketHandler = terminalWebSocketHandler; + this.terminalHandshakeInterceptor = terminalHandshakeInterceptor; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(terminalWebSocketHandler, "/ws/terminal") + .addInterceptors(terminalHandshakeInterceptor) + .setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173"); + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/AuthController.java b/backend/src/main/java/com/sshmanager/controller/AuthController.java index f26d3cd..ccf66fe 100644 --- a/backend/src/main/java/com/sshmanager/controller/AuthController.java +++ b/backend/src/main/java/com/sshmanager/controller/AuthController.java @@ -1,69 +1,69 @@ -package com.sshmanager.controller; - -import com.sshmanager.dto.LoginRequest; -import com.sshmanager.dto.LoginResponse; -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import com.sshmanager.security.JwtTokenProvider; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api/auth") -public class AuthController { - - private final AuthenticationManager authenticationManager; - private final JwtTokenProvider tokenProvider; - private final UserRepository userRepository; - - public AuthController(AuthenticationManager authenticationManager, - JwtTokenProvider tokenProvider, - UserRepository userRepository) { - this.authenticationManager = authenticationManager; - this.tokenProvider = tokenProvider; - this.userRepository = userRepository; - } - - @PostMapping("/login") - public ResponseEntity> login(@RequestBody LoginRequest request) { - try { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); - - SecurityContextHolder.getContext().setAuthentication(authentication); - String token = tokenProvider.generateToken(authentication); - - User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found")); - LoginResponse response = new LoginResponse(token, user.getUsername(), - user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); - - return ResponseEntity.ok(response); - } catch (BadCredentialsException e) { - Map error = new HashMap<>(); - error.put("message", "Invalid username or password"); - return ResponseEntity.status(401).body(error); - } - } - - @GetMapping("/me") - public ResponseEntity> me(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - Map error = new HashMap<>(); - error.put("error", "Unauthorized"); - return ResponseEntity.status(401).body(error); - } - User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); - Map data = new HashMap<>(); - data.put("username", user.getUsername()); - data.put("displayName", user.getDisplayName()); - return ResponseEntity.ok(data); - } -} +package com.sshmanager.controller; + +import com.sshmanager.dto.LoginRequest; +import com.sshmanager.dto.LoginResponse; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.security.JwtTokenProvider; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider tokenProvider; + private final UserRepository userRepository; + + public AuthController(AuthenticationManager authenticationManager, + JwtTokenProvider tokenProvider, + UserRepository userRepository) { + this.authenticationManager = authenticationManager; + this.tokenProvider = tokenProvider; + this.userRepository = userRepository; + } + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody LoginRequest request) { + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + String token = tokenProvider.generateToken(authentication); + + User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found")); + LoginResponse response = new LoginResponse(token, user.getUsername(), + user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); + + return ResponseEntity.ok(response); + } catch (BadCredentialsException e) { + Map error = new HashMap<>(); + error.put("message", "Invalid username or password"); + return ResponseEntity.status(401).body(error); + } + } + + @GetMapping("/me") + public ResponseEntity> me(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + Map error = new HashMap<>(); + error.put("error", "Unauthorized"); + return ResponseEntity.status(401).body(error); + } + User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + Map data = new HashMap<>(); + data.put("username", user.getUsername()); + data.put("displayName", user.getDisplayName()); + return ResponseEntity.ok(data); + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java index 02e2b3e..a0dbe07 100644 --- a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java +++ b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java @@ -1,70 +1,70 @@ -package com.sshmanager.controller; - -import com.sshmanager.dto.ConnectionCreateRequest; -import com.sshmanager.dto.ConnectionDto; -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import com.sshmanager.service.ConnectionService; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/connections") -public class ConnectionController { - - private final ConnectionService connectionService; - private final UserRepository userRepository; - - public ConnectionController(ConnectionService connectionService, - UserRepository userRepository) { - this.connectionService = connectionService; - this.userRepository = userRepository; - } - - private Long getCurrentUserId(Authentication auth) { - User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); - return user.getId(); - } - - @GetMapping - public ResponseEntity> list(Authentication authentication) { - Long userId = getCurrentUserId(authentication); - return ResponseEntity.ok(connectionService.listByUserId(userId)); - } - - @GetMapping("/{id}") - public ResponseEntity get(@PathVariable Long id, Authentication authentication) { - Long userId = getCurrentUserId(authentication); - return ResponseEntity.ok(connectionService.getById(id, userId)); - } - - @PostMapping - public ResponseEntity create(@RequestBody ConnectionCreateRequest request, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - return ResponseEntity.ok(connectionService.create(request, userId)); - } - - @PutMapping("/{id}") - public ResponseEntity update(@PathVariable Long id, - @RequestBody ConnectionCreateRequest request, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - return ResponseEntity.ok(connectionService.update(id, request, userId)); - } - - @DeleteMapping("/{id}") - public ResponseEntity> delete(@PathVariable Long id, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - connectionService.delete(id, userId); - Map result = new HashMap<>(); - result.put("message", "Deleted"); - return ResponseEntity.ok(result); - } -} +package com.sshmanager.controller; + +import com.sshmanager.dto.ConnectionCreateRequest; +import com.sshmanager.dto.ConnectionDto; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.ConnectionService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/connections") +public class ConnectionController { + + private final ConnectionService connectionService; + private final UserRepository userRepository; + + public ConnectionController(ConnectionService connectionService, + UserRepository userRepository) { + this.connectionService = connectionService; + this.userRepository = userRepository; + } + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + return user.getId(); + } + + @GetMapping + public ResponseEntity> list(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(connectionService.listByUserId(userId)); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Long id, Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(connectionService.getById(id, userId)); + } + + @PostMapping + public ResponseEntity create(@RequestBody ConnectionCreateRequest request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(connectionService.create(request, userId)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody ConnectionCreateRequest request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(connectionService.update(id, request, userId)); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + connectionService.delete(id, userId); + Map result = new HashMap<>(); + result.put("message", "Deleted"); + return ResponseEntity.ok(result); + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/SftpController.java b/backend/src/main/java/com/sshmanager/controller/SftpController.java index c07804c..e9c5f88 100644 --- a/backend/src/main/java/com/sshmanager/controller/SftpController.java +++ b/backend/src/main/java/com/sshmanager/controller/SftpController.java @@ -1,248 +1,248 @@ -package com.sshmanager.controller; - -import com.sshmanager.dto.SftpFileInfo; -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.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -@RestController -@RequestMapping("/api/sftp") -public class SftpController { - - private final ConnectionService connectionService; - private final UserRepository userRepository; - private final SftpService sftpService; - - private final Map sessions = new ConcurrentHashMap<>(); - - public SftpController(ConnectionService connectionService, - UserRepository userRepository, - SftpService sftpService) { - this.connectionService = connectionService; - this.userRepository = userRepository; - this.sftpService = sftpService; - } - - private Long getCurrentUserId(Authentication auth) { - User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); - return user.getId(); - } - - private String sessionKey(Long userId, Long connectionId) { - return userId + ":" + connectionId; - } - - private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception { - String key = sessionKey(userId, connectionId); - SftpService.SftpSession session = sessions.get(key); - if (session == null || !session.isConnected()) { - Connection conn = connectionService.getConnectionForSsh(connectionId, userId); - String password = connectionService.getDecryptedPassword(conn); - String privateKey = connectionService.getDecryptedPrivateKey(conn); - String passphrase = connectionService.getDecryptedPassphrase(conn); - session = sftpService.connect(conn, password, privateKey, passphrase); - sessions.put(key, session); - } - return session; - } - - @GetMapping("/list") - public ResponseEntity> list( - @RequestParam Long connectionId, - @RequestParam(required = false, defaultValue = ".") String path, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - List files = sftpService.listFiles(session, path); - List 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) { - return ResponseEntity.status(500).build(); - } - } - - @GetMapping("/pwd") - public ResponseEntity> pwd( - @RequestParam Long connectionId, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - String pwd = sftpService.pwd(session); - Map result = new HashMap<>(); - result.put("path", pwd); - return ResponseEntity.ok(result); - } catch (Exception e) { - return ResponseEntity.status(500).build(); - } - } - - @GetMapping("/download") - public ResponseEntity download( - @RequestParam Long connectionId, - @RequestParam String path, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - byte[] data = sftpService.download(session, path); - String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(data); - } catch (Exception e) { - return ResponseEntity.status(500).build(); - } - } - - @PostMapping("/upload") - public ResponseEntity> upload( - @RequestParam Long connectionId, - @RequestParam String path, - @RequestParam("file") MultipartFile file, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - String remotePath = (path == null || path.isEmpty() || path.equals("/")) - ? "/" + file.getOriginalFilename() - : (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename()); - sftpService.upload(session, remotePath, file.getBytes()); - Map result = new HashMap<>(); - result.put("message", "Uploaded"); - return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @DeleteMapping("/delete") - public ResponseEntity> delete( - @RequestParam Long connectionId, - @RequestParam String path, - @RequestParam boolean directory, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - sftpService.delete(session, path, directory); - Map result = new HashMap<>(); - result.put("message", "Deleted"); - return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/mkdir") - public ResponseEntity> mkdir( - @RequestParam Long connectionId, - @RequestParam String path, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - sftpService.mkdir(session, path); - Map result = new HashMap<>(); - result.put("message", "Created"); - return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/rename") - public ResponseEntity> rename( - @RequestParam Long connectionId, - @RequestParam String oldPath, - @RequestParam String newPath, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - sftpService.rename(session, oldPath, newPath); - Map result = new HashMap<>(); - result.put("message", "Renamed"); - return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/transfer-remote") - public ResponseEntity> transferRemote( - @RequestParam Long sourceConnectionId, - @RequestParam String sourcePath, - @RequestParam Long targetConnectionId, - @RequestParam String targetPath, - Authentication authentication) { - try { - Long userId = getCurrentUserId(authentication); - if (sourcePath == null || sourcePath.trim().isEmpty()) { - Map err = new HashMap<>(); - err.put("error", "sourcePath is required"); - return ResponseEntity.badRequest().body(err); - } - if (targetPath == null || targetPath.trim().isEmpty()) { - Map err = new HashMap<>(); - err.put("error", "targetPath is required"); - return ResponseEntity.badRequest().body(err); - } - SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId); - SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId); - if (sourceConnectionId.equals(targetConnectionId)) { - sftpService.rename(sourceSession, sourcePath.trim(), targetPath.trim()); - } else { - sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim()); - } - Map result = new HashMap<>(); - result.put("message", "Transferred"); - return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed"); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/disconnect") - public ResponseEntity> disconnect( - @RequestParam Long connectionId, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - String key = sessionKey(userId, connectionId); - SftpService.SftpSession session = sessions.remove(key); - if (session != null) { - session.disconnect(); - } - Map result = new HashMap<>(); - result.put("message", "Disconnected"); - return ResponseEntity.ok(result); - } -} +package com.sshmanager.controller; + +import com.sshmanager.dto.SftpFileInfo; +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.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/sftp") +public class SftpController { + + private final ConnectionService connectionService; + private final UserRepository userRepository; + private final SftpService sftpService; + + private final Map sessions = new ConcurrentHashMap<>(); + + public SftpController(ConnectionService connectionService, + UserRepository userRepository, + SftpService sftpService) { + this.connectionService = connectionService; + this.userRepository = userRepository; + this.sftpService = sftpService; + } + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + return user.getId(); + } + + private String sessionKey(Long userId, Long connectionId) { + return userId + ":" + connectionId; + } + + private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception { + String key = sessionKey(userId, connectionId); + SftpService.SftpSession session = sessions.get(key); + if (session == null || !session.isConnected()) { + Connection conn = connectionService.getConnectionForSsh(connectionId, userId); + String password = connectionService.getDecryptedPassword(conn); + String privateKey = connectionService.getDecryptedPrivateKey(conn); + String passphrase = connectionService.getDecryptedPassphrase(conn); + session = sftpService.connect(conn, password, privateKey, passphrase); + sessions.put(key, session); + } + return session; + } + + @GetMapping("/list") + public ResponseEntity> list( + @RequestParam Long connectionId, + @RequestParam(required = false, defaultValue = ".") String path, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + List files = sftpService.listFiles(session, path); + List 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) { + return ResponseEntity.status(500).build(); + } + } + + @GetMapping("/pwd") + public ResponseEntity> pwd( + @RequestParam Long connectionId, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + String pwd = sftpService.pwd(session); + Map result = new HashMap<>(); + result.put("path", pwd); + return ResponseEntity.ok(result); + } catch (Exception e) { + return ResponseEntity.status(500).build(); + } + } + + @GetMapping("/download") + public ResponseEntity download( + @RequestParam Long connectionId, + @RequestParam String path, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + byte[] data = sftpService.download(session, path); + String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(data); + } catch (Exception e) { + return ResponseEntity.status(500).build(); + } + } + + @PostMapping("/upload") + public ResponseEntity> upload( + @RequestParam Long connectionId, + @RequestParam String path, + @RequestParam("file") MultipartFile file, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + String remotePath = (path == null || path.isEmpty() || path.equals("/")) + ? "/" + file.getOriginalFilename() + : (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename()); + sftpService.upload(session, remotePath, file.getBytes()); + Map result = new HashMap<>(); + result.put("message", "Uploaded"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @DeleteMapping("/delete") + public ResponseEntity> delete( + @RequestParam Long connectionId, + @RequestParam String path, + @RequestParam boolean directory, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + sftpService.delete(session, path, directory); + Map result = new HashMap<>(); + result.put("message", "Deleted"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/mkdir") + public ResponseEntity> mkdir( + @RequestParam Long connectionId, + @RequestParam String path, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + sftpService.mkdir(session, path); + Map result = new HashMap<>(); + result.put("message", "Created"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/rename") + public ResponseEntity> rename( + @RequestParam Long connectionId, + @RequestParam String oldPath, + @RequestParam String newPath, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + sftpService.rename(session, oldPath, newPath); + Map result = new HashMap<>(); + result.put("message", "Renamed"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/transfer-remote") + public ResponseEntity> transferRemote( + @RequestParam Long sourceConnectionId, + @RequestParam String sourcePath, + @RequestParam Long targetConnectionId, + @RequestParam String targetPath, + Authentication authentication) { + try { + Long userId = getCurrentUserId(authentication); + if (sourcePath == null || sourcePath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "sourcePath is required"); + return ResponseEntity.badRequest().body(err); + } + if (targetPath == null || targetPath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "targetPath is required"); + return ResponseEntity.badRequest().body(err); + } + SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId); + SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId); + if (sourceConnectionId.equals(targetConnectionId)) { + sftpService.rename(sourceSession, sourcePath.trim(), targetPath.trim()); + } else { + sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim()); + } + Map result = new HashMap<>(); + result.put("message", "Transferred"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed"); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/disconnect") + public ResponseEntity> disconnect( + @RequestParam Long connectionId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + String key = sessionKey(userId, connectionId); + SftpService.SftpSession session = sessions.remove(key); + if (session != null) { + session.disconnect(); + } + Map result = new HashMap<>(); + result.put("message", "Disconnected"); + return ResponseEntity.ok(result); + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java index 530d75f..276db56 100644 --- a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java +++ b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java @@ -1,112 +1,112 @@ -package com.sshmanager.controller; - -import com.sshmanager.entity.Connection; -import com.sshmanager.entity.User; -import com.sshmanager.repository.ConnectionRepository; -import com.sshmanager.repository.UserRepository; -import com.sshmanager.service.ConnectionService; -import com.sshmanager.service.SshService; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -@Component -public class TerminalWebSocketHandler extends TextWebSocketHandler { - - private final ConnectionRepository connectionRepository; - private final UserRepository userRepository; - private final ConnectionService connectionService; - private final SshService sshService; - - private final ExecutorService executor = Executors.newCachedThreadPool(); - private final Map sessions = new ConcurrentHashMap<>(); - - public TerminalWebSocketHandler(ConnectionRepository connectionRepository, - UserRepository userRepository, - ConnectionService connectionService, - SshService sshService) { - this.connectionRepository = connectionRepository; - this.userRepository = userRepository; - this.connectionService = connectionService; - this.sshService = sshService; - } - - @Override - public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { - Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId"); - String username = (String) webSocketSession.getAttributes().get("username"); - - if (connectionId == null || username == null) { - webSocketSession.close(CloseStatus.BAD_DATA); - return; - } - - User user = userRepository.findByUsername(username).orElse(null); - if (user == null) { - webSocketSession.close(CloseStatus.BAD_DATA); - return; - } - - Connection conn = connectionRepository.findById(connectionId).orElse(null); - if (conn == null || !conn.getUserId().equals(user.getId())) { - webSocketSession.close(CloseStatus.BAD_DATA); - return; - } - - String password = connectionService.getDecryptedPassword(conn); - String privateKey = connectionService.getDecryptedPrivateKey(conn); - String passphrase = connectionService.getDecryptedPassphrase(conn); - - try { - SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase); - sessions.put(webSocketSession.getId(), sshSession); - - executor.submit(() -> { - try { - InputStream in = sshSession.getOutputStream(); - byte[] buf = new byte[4096]; - int n; - while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) { - String text = new String(buf, 0, n, "UTF-8"); - webSocketSession.sendMessage(new TextMessage(text)); - } - } catch (Exception e) { - if (webSocketSession.isOpen()) { - try { - webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n")); - } catch (IOException ignored) { - } - } - } - }); - } catch (Exception e) { - webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n")); - } - } - - @Override - protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception { - SshService.SshSession sshSession = sessions.get(webSocketSession.getId()); - if (sshSession != null && sshSession.isConnected()) { - sshSession.getInputStream().write(message.asBytes()); - sshSession.getInputStream().flush(); - } - } - - @Override - public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception { - SshService.SshSession sshSession = sessions.remove(webSocketSession.getId()); - if (sshSession != null) { - sshSession.disconnect(); - } - } -} +package com.sshmanager.controller; + +import com.sshmanager.entity.Connection; +import com.sshmanager.entity.User; +import com.sshmanager.repository.ConnectionRepository; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.ConnectionService; +import com.sshmanager.service.SshService; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Component +public class TerminalWebSocketHandler extends TextWebSocketHandler { + + private final ConnectionRepository connectionRepository; + private final UserRepository userRepository; + private final ConnectionService connectionService; + private final SshService sshService; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final Map sessions = new ConcurrentHashMap<>(); + + public TerminalWebSocketHandler(ConnectionRepository connectionRepository, + UserRepository userRepository, + ConnectionService connectionService, + SshService sshService) { + this.connectionRepository = connectionRepository; + this.userRepository = userRepository; + this.connectionService = connectionService; + this.sshService = sshService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { + Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId"); + String username = (String) webSocketSession.getAttributes().get("username"); + + if (connectionId == null || username == null) { + webSocketSession.close(CloseStatus.BAD_DATA); + return; + } + + User user = userRepository.findByUsername(username).orElse(null); + if (user == null) { + webSocketSession.close(CloseStatus.BAD_DATA); + return; + } + + Connection conn = connectionRepository.findById(connectionId).orElse(null); + if (conn == null || !conn.getUserId().equals(user.getId())) { + webSocketSession.close(CloseStatus.BAD_DATA); + return; + } + + String password = connectionService.getDecryptedPassword(conn); + String privateKey = connectionService.getDecryptedPrivateKey(conn); + String passphrase = connectionService.getDecryptedPassphrase(conn); + + try { + SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase); + sessions.put(webSocketSession.getId(), sshSession); + + executor.submit(() -> { + try { + InputStream in = sshSession.getOutputStream(); + byte[] buf = new byte[4096]; + int n; + while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) { + String text = new String(buf, 0, n, "UTF-8"); + webSocketSession.sendMessage(new TextMessage(text)); + } + } catch (Exception e) { + if (webSocketSession.isOpen()) { + try { + webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n")); + } catch (IOException ignored) { + } + } + } + }); + } catch (Exception e) { + webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n")); + } + } + + @Override + protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception { + SshService.SshSession sshSession = sessions.get(webSocketSession.getId()); + if (sshSession != null && sshSession.isConnected()) { + sshSession.getInputStream().write(message.asBytes()); + sshSession.getInputStream().flush(); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception { + SshService.SshSession sshSession = sessions.remove(webSocketSession.getId()); + if (sshSession != null) { + sshSession.disconnect(); + } + } +} diff --git a/backend/src/main/java/com/sshmanager/dto/ConnectionCreateRequest.java b/backend/src/main/java/com/sshmanager/dto/ConnectionCreateRequest.java index 905daeb..65a4229 100644 --- a/backend/src/main/java/com/sshmanager/dto/ConnectionCreateRequest.java +++ b/backend/src/main/java/com/sshmanager/dto/ConnectionCreateRequest.java @@ -1,16 +1,16 @@ -package com.sshmanager.dto; - -import com.sshmanager.entity.Connection; -import lombok.Data; - -@Data -public class ConnectionCreateRequest { - private String name; - private String host; - private Integer port = 22; - private String username; - private Connection.AuthType authType = Connection.AuthType.PASSWORD; - private String password; - private String privateKey; - private String passphrase; -} +package com.sshmanager.dto; + +import com.sshmanager.entity.Connection; +import lombok.Data; + +@Data +public class ConnectionCreateRequest { + private String name; + private String host; + private Integer port = 22; + private String username; + private Connection.AuthType authType = Connection.AuthType.PASSWORD; + private String password; + private String privateKey; + private String passphrase; +} diff --git a/backend/src/main/java/com/sshmanager/dto/ConnectionDto.java b/backend/src/main/java/com/sshmanager/dto/ConnectionDto.java index 99e9dcb..1f27d80 100644 --- a/backend/src/main/java/com/sshmanager/dto/ConnectionDto.java +++ b/backend/src/main/java/com/sshmanager/dto/ConnectionDto.java @@ -1,29 +1,29 @@ -package com.sshmanager.dto; - -import com.sshmanager.entity.Connection; -import lombok.Data; - -@Data -public class ConnectionDto { - private Long id; - private String name; - private String host; - private Integer port; - private String username; - private Connection.AuthType authType; - private String createdAt; - private String updatedAt; - - public static ConnectionDto fromEntity(Connection conn) { - ConnectionDto dto = new ConnectionDto(); - dto.setId(conn.getId()); - dto.setName(conn.getName()); - dto.setHost(conn.getHost()); - dto.setPort(conn.getPort()); - dto.setUsername(conn.getUsername()); - dto.setAuthType(conn.getAuthType()); - dto.setCreatedAt(conn.getCreatedAt().toString()); - dto.setUpdatedAt(conn.getUpdatedAt().toString()); - return dto; - } -} +package com.sshmanager.dto; + +import com.sshmanager.entity.Connection; +import lombok.Data; + +@Data +public class ConnectionDto { + private Long id; + private String name; + private String host; + private Integer port; + private String username; + private Connection.AuthType authType; + private String createdAt; + private String updatedAt; + + public static ConnectionDto fromEntity(Connection conn) { + ConnectionDto dto = new ConnectionDto(); + dto.setId(conn.getId()); + dto.setName(conn.getName()); + dto.setHost(conn.getHost()); + dto.setPort(conn.getPort()); + dto.setUsername(conn.getUsername()); + dto.setAuthType(conn.getAuthType()); + dto.setCreatedAt(conn.getCreatedAt().toString()); + dto.setUpdatedAt(conn.getUpdatedAt().toString()); + return dto; + } +} diff --git a/backend/src/main/java/com/sshmanager/dto/LoginRequest.java b/backend/src/main/java/com/sshmanager/dto/LoginRequest.java index 7b9a95e..d440ec8 100644 --- a/backend/src/main/java/com/sshmanager/dto/LoginRequest.java +++ b/backend/src/main/java/com/sshmanager/dto/LoginRequest.java @@ -1,9 +1,9 @@ -package com.sshmanager.dto; - -import lombok.Data; - -@Data -public class LoginRequest { - private String username; - private String password; -} +package com.sshmanager.dto; + +import lombok.Data; + +@Data +public class LoginRequest { + private String username; + private String password; +} diff --git a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java index 1e87c30..c77b9dd 100644 --- a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java +++ b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java @@ -1,14 +1,14 @@ -package com.sshmanager.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class LoginResponse { - private String token; - private String username; - private String displayName; -} +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + private String token; + private String username; + private String displayName; +} diff --git a/backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java b/backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java index 3e0ebc2..c17f927 100644 --- a/backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java +++ b/backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java @@ -1,15 +1,15 @@ -package com.sshmanager.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class SftpFileInfo { - private String name; - private boolean directory; - private long size; - private long mtime; -} +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SftpFileInfo { + private String name; + private boolean directory; + private long size; + private long mtime; +} diff --git a/backend/src/main/java/com/sshmanager/entity/Connection.java b/backend/src/main/java/com/sshmanager/entity/Connection.java index 3a57877..72382ff 100644 --- a/backend/src/main/java/com/sshmanager/entity/Connection.java +++ b/backend/src/main/java/com/sshmanager/entity/Connection.java @@ -1,59 +1,59 @@ -package com.sshmanager.entity; - -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; - -import javax.persistence.*; -import java.time.Instant; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "connections") -public class Connection { - - public enum AuthType { - PASSWORD, - PRIVATE_KEY - } - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private Long userId; - - @Column(nullable = false, length = 100) - private String name; - - @Column(nullable = false, length = 255) - private String host; - - @Column(nullable = false) - private Integer port = 22; - - @Column(nullable = false, length = 100) - private String username; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private AuthType authType = AuthType.PASSWORD; - - @Column(columnDefinition = "TEXT") - private String encryptedPassword; - - @Column(columnDefinition = "TEXT") - private String encryptedPrivateKey; - - @Column(length = 255) - private String passphrase; - - @Column(nullable = false) - private Instant createdAt = Instant.now(); - - @Column(nullable = false) - private Instant updatedAt = Instant.now(); -} +package com.sshmanager.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import javax.persistence.*; +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "connections") +public class Connection { + + public enum AuthType { + PASSWORD, + PRIVATE_KEY + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false, length = 255) + private String host; + + @Column(nullable = false) + private Integer port = 22; + + @Column(nullable = false, length = 100) + private String username; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AuthType authType = AuthType.PASSWORD; + + @Column(columnDefinition = "TEXT") + private String encryptedPassword; + + @Column(columnDefinition = "TEXT") + private String encryptedPrivateKey; + + @Column(length = 255) + private String passphrase; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + @Column(nullable = false) + private Instant updatedAt = Instant.now(); +} diff --git a/backend/src/main/java/com/sshmanager/entity/User.java b/backend/src/main/java/com/sshmanager/entity/User.java index c7e6fff..daa8676 100644 --- a/backend/src/main/java/com/sshmanager/entity/User.java +++ b/backend/src/main/java/com/sshmanager/entity/User.java @@ -1,35 +1,35 @@ -package com.sshmanager.entity; - -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; - -import javax.persistence.*; -import java.time.Instant; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "users") -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 50) - private String username; - - @Column(nullable = false, length = 255) - private String passwordHash; - - @Column(length = 100) - private String displayName; - - @Column(nullable = false) - private Instant createdAt = Instant.now(); - - @Column(nullable = false) - private Instant updatedAt = Instant.now(); -} +package com.sshmanager.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import javax.persistence.*; +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(nullable = false, length = 255) + private String passwordHash; + + @Column(length = 100) + private String displayName; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + @Column(nullable = false) + private Instant updatedAt = Instant.now(); +} diff --git a/backend/src/main/java/com/sshmanager/repository/ConnectionRepository.java b/backend/src/main/java/com/sshmanager/repository/ConnectionRepository.java index 6b11e9e..7eaf431 100644 --- a/backend/src/main/java/com/sshmanager/repository/ConnectionRepository.java +++ b/backend/src/main/java/com/sshmanager/repository/ConnectionRepository.java @@ -1,11 +1,11 @@ -package com.sshmanager.repository; - -import com.sshmanager.entity.Connection; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ConnectionRepository extends JpaRepository { - - List findByUserIdOrderByUpdatedAtDesc(Long userId); -} +package com.sshmanager.repository; + +import com.sshmanager.entity.Connection; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ConnectionRepository extends JpaRepository { + + List findByUserIdOrderByUpdatedAtDesc(Long userId); +} diff --git a/backend/src/main/java/com/sshmanager/repository/UserRepository.java b/backend/src/main/java/com/sshmanager/repository/UserRepository.java index 5f7e11c..879d241 100644 --- a/backend/src/main/java/com/sshmanager/repository/UserRepository.java +++ b/backend/src/main/java/com/sshmanager/repository/UserRepository.java @@ -1,13 +1,13 @@ -package com.sshmanager.repository; - -import com.sshmanager.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - - Optional findByUsername(String username); - - boolean existsByUsername(String username); -} +package com.sshmanager.repository; + +import com.sshmanager.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java b/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java index 8ecfdca..4e268c8 100644 --- a/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java +++ b/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java @@ -1,32 +1,32 @@ -package com.sshmanager.security; - -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -import java.util.Collections; - -@Service -public class CustomUserDetailsService implements UserDetailsService { - - private final UserRepository userRepository; - - public CustomUserDetailsService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); - - return new org.springframework.security.core.userdetails.User( - user.getUsername(), - user.getPasswordHash(), - Collections.emptyList() - ); - } -} +package com.sshmanager.security; + +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPasswordHash(), + Collections.emptyList() + ); + } +} diff --git a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java index b285d53..92d9e9c 100644 --- a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java @@ -1,57 +1,57 @@ -package com.sshmanager.security; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtTokenProvider tokenProvider; - private final UserDetailsService userDetailsService; - - public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) { - this.tokenProvider = tokenProvider; - this.userDetailsService = userDetailsService; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - String jwt = getJwtFromRequest(request); - if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { - String username = tokenProvider.getUsernameFromToken(jwt); - UserDetails userDetails = userDetailsService.loadUserByUsername(username); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } catch (Exception ex) { - // Log but don't fail - let controller handle 401 - } - filterChain.doFilter(request, response); - } - - private String getJwtFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - return null; - } -} +package com.sshmanager.security; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final UserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) { + this.tokenProvider = tokenProvider; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + String username = tokenProvider.getUsernameFromToken(jwt); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + // Log but don't fail - let controller handle 401 + } + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/backend/src/main/java/com/sshmanager/security/JwtTokenProvider.java b/backend/src/main/java/com/sshmanager/security/JwtTokenProvider.java index 9e23d43..ed02708 100644 --- a/backend/src/main/java/com/sshmanager/security/JwtTokenProvider.java +++ b/backend/src/main/java/com/sshmanager/security/JwtTokenProvider.java @@ -1,65 +1,65 @@ -package com.sshmanager.security; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; - -import javax.annotation.PostConstruct; -import java.security.Key; -import java.util.Date; - -@Component -public class JwtTokenProvider { - - @Value("${sshmanager.jwt-secret}") - private String jwtSecret; - - @Value("${sshmanager.jwt-expiration-ms}") - private long jwtExpirationMs; - - private Key key; - - @PostConstruct - public void init() { - byte[] keyBytes = jwtSecret.getBytes(); - if (keyBytes.length < 32) { - byte[] padded = new byte[32]; - System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32)); - keyBytes = padded; - } - this.key = Keys.hmacShaKeyFor(keyBytes); - } - - public String generateToken(Authentication authentication) { - String username = authentication.getName(); - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + jwtExpirationMs); - - return Jwts.builder() - .setSubject(username) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(key) - .compact(); - } - - public String getUsernameFromToken(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.getSubject(); - } - - public boolean validateToken(String authToken) { - try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken); - return true; - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } -} +package com.sshmanager.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.security.Key; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + @Value("${sshmanager.jwt-secret}") + private String jwtSecret; + + @Value("${sshmanager.jwt-expiration-ms}") + private long jwtExpirationMs; + + private Key key; + + @PostConstruct + public void init() { + byte[] keyBytes = jwtSecret.getBytes(); + if (keyBytes.length < 32) { + byte[] padded = new byte[32]; + System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32)); + keyBytes = padded; + } + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(Authentication authentication) { + String username = authentication.getName(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtExpirationMs); + + return Jwts.builder() + .setSubject(username) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(key) + .compact(); + } + + public String getUsernameFromToken(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } + + public boolean validateToken(String authToken) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} diff --git a/backend/src/main/java/com/sshmanager/service/ConnectionService.java b/backend/src/main/java/com/sshmanager/service/ConnectionService.java index 7a2e647..38aa2d2 100644 --- a/backend/src/main/java/com/sshmanager/service/ConnectionService.java +++ b/backend/src/main/java/com/sshmanager/service/ConnectionService.java @@ -1,127 +1,127 @@ -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.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.List; -import java.util.stream.Collectors; - -@Service -public class ConnectionService { - - private final ConnectionRepository connectionRepository; - private final EncryptionService encryptionService; - - public ConnectionService(ConnectionRepository connectionRepository, - EncryptionService encryptionService) { - this.connectionRepository = connectionRepository; - this.encryptionService = encryptionService; - } - - public List listByUserId(Long userId) { - return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream() - .map(ConnectionDto::fromEntity) - .collect(Collectors.toList()); - } - - public ConnectionDto getById(Long id, Long userId) { - Connection conn = connectionRepository.findById(id).orElseThrow( - () -> new RuntimeException("Connection not found: " + id)); - if (!conn.getUserId().equals(userId)) { - throw new RuntimeException("Access denied"); - } - return ConnectionDto.fromEntity(conn); - } - - @Transactional - public ConnectionDto create(ConnectionCreateRequest request, Long userId) { - Connection conn = new Connection(); - conn.setUserId(userId); - conn.setName(request.getName()); - conn.setHost(request.getHost()); - conn.setPort(request.getPort() != null ? request.getPort() : 22); - conn.setUsername(request.getUsername()); - conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD); - - if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) { - conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword())); - } else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) { - conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey())); - if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) { - conn.setPassphrase(encryptionService.encrypt(request.getPassphrase())); - } - } - - conn = connectionRepository.save(conn); - return ConnectionDto.fromEntity(conn); - } - - @Transactional - public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) { - Connection conn = connectionRepository.findById(id).orElseThrow( - () -> new RuntimeException("Connection not found: " + id)); - if (!conn.getUserId().equals(userId)) { - throw new RuntimeException("Access denied"); - } - - if (request.getName() != null) conn.setName(request.getName()); - if (request.getHost() != null) conn.setHost(request.getHost()); - if (request.getPort() != null) conn.setPort(request.getPort()); - if (request.getUsername() != null) conn.setUsername(request.getUsername()); - if (request.getAuthType() != null) conn.setAuthType(request.getAuthType()); - - if (request.getPassword() != null) { - conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword())); - } - if (request.getPrivateKey() != null) { - conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey())); - } - if (request.getPassphrase() != null) { - conn.setPassphrase(request.getPassphrase().isEmpty() ? null : - encryptionService.encrypt(request.getPassphrase())); - } - - conn.setUpdatedAt(Instant.now()); - conn = connectionRepository.save(conn); - return ConnectionDto.fromEntity(conn); - } - - @Transactional - public void delete(Long id, Long userId) { - Connection conn = connectionRepository.findById(id).orElseThrow( - () -> new RuntimeException("Connection not found: " + id)); - if (!conn.getUserId().equals(userId)) { - throw new RuntimeException("Access denied"); - } - connectionRepository.delete(conn); - } - - public Connection getConnectionForSsh(Long id, Long userId) { - Connection conn = connectionRepository.findById(id).orElseThrow( - () -> new RuntimeException("Connection not found: " + id)); - if (!conn.getUserId().equals(userId)) { - throw new RuntimeException("Access denied"); - } - return conn; - } - - public String getDecryptedPassword(Connection conn) { - return conn.getEncryptedPassword() != null ? - encryptionService.decrypt(conn.getEncryptedPassword()) : null; - } - - public String getDecryptedPrivateKey(Connection conn) { - return conn.getEncryptedPrivateKey() != null ? - encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null; - } - - public String getDecryptedPassphrase(Connection conn) { - return conn.getPassphrase() != null ? - encryptionService.decrypt(conn.getPassphrase()) : null; - } -} +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.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class ConnectionService { + + private final ConnectionRepository connectionRepository; + private final EncryptionService encryptionService; + + public ConnectionService(ConnectionRepository connectionRepository, + EncryptionService encryptionService) { + this.connectionRepository = connectionRepository; + this.encryptionService = encryptionService; + } + + public List listByUserId(Long userId) { + return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream() + .map(ConnectionDto::fromEntity) + .collect(Collectors.toList()); + } + + public ConnectionDto getById(Long id, Long userId) { + Connection conn = connectionRepository.findById(id).orElseThrow( + () -> new RuntimeException("Connection not found: " + id)); + if (!conn.getUserId().equals(userId)) { + throw new RuntimeException("Access denied"); + } + return ConnectionDto.fromEntity(conn); + } + + @Transactional + public ConnectionDto create(ConnectionCreateRequest request, Long userId) { + Connection conn = new Connection(); + conn.setUserId(userId); + conn.setName(request.getName()); + conn.setHost(request.getHost()); + conn.setPort(request.getPort() != null ? request.getPort() : 22); + conn.setUsername(request.getUsername()); + conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD); + + if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) { + conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword())); + } else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) { + conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey())); + if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) { + conn.setPassphrase(encryptionService.encrypt(request.getPassphrase())); + } + } + + conn = connectionRepository.save(conn); + return ConnectionDto.fromEntity(conn); + } + + @Transactional + public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) { + Connection conn = connectionRepository.findById(id).orElseThrow( + () -> new RuntimeException("Connection not found: " + id)); + if (!conn.getUserId().equals(userId)) { + throw new RuntimeException("Access denied"); + } + + if (request.getName() != null) conn.setName(request.getName()); + if (request.getHost() != null) conn.setHost(request.getHost()); + if (request.getPort() != null) conn.setPort(request.getPort()); + if (request.getUsername() != null) conn.setUsername(request.getUsername()); + if (request.getAuthType() != null) conn.setAuthType(request.getAuthType()); + + if (request.getPassword() != null) { + conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword())); + } + if (request.getPrivateKey() != null) { + conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey())); + } + if (request.getPassphrase() != null) { + conn.setPassphrase(request.getPassphrase().isEmpty() ? null : + encryptionService.encrypt(request.getPassphrase())); + } + + conn.setUpdatedAt(Instant.now()); + conn = connectionRepository.save(conn); + return ConnectionDto.fromEntity(conn); + } + + @Transactional + public void delete(Long id, Long userId) { + Connection conn = connectionRepository.findById(id).orElseThrow( + () -> new RuntimeException("Connection not found: " + id)); + if (!conn.getUserId().equals(userId)) { + throw new RuntimeException("Access denied"); + } + connectionRepository.delete(conn); + } + + public Connection getConnectionForSsh(Long id, Long userId) { + Connection conn = connectionRepository.findById(id).orElseThrow( + () -> new RuntimeException("Connection not found: " + id)); + if (!conn.getUserId().equals(userId)) { + throw new RuntimeException("Access denied"); + } + return conn; + } + + public String getDecryptedPassword(Connection conn) { + return conn.getEncryptedPassword() != null ? + encryptionService.decrypt(conn.getEncryptedPassword()) : null; + } + + public String getDecryptedPrivateKey(Connection conn) { + return conn.getEncryptedPrivateKey() != null ? + encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null; + } + + public String getDecryptedPassphrase(Connection conn) { + return conn.getPassphrase() != null ? + encryptionService.decrypt(conn.getPassphrase()) : null; + } +} diff --git a/backend/src/main/java/com/sshmanager/service/EncryptionService.java b/backend/src/main/java/com/sshmanager/service/EncryptionService.java index 0efabcd..d0f9811 100644 --- a/backend/src/main/java/com/sshmanager/service/EncryptionService.java +++ b/backend/src/main/java/com/sshmanager/service/EncryptionService.java @@ -1,77 +1,77 @@ -package com.sshmanager.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import javax.crypto.Cipher; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; - -@Service -public class EncryptionService { - - private static final int GCM_IV_LENGTH = 12; - private static final int GCM_TAG_LENGTH = 128; - private static final String ALGORITHM = "AES/GCM/NoPadding"; - - private final byte[] keyBytes; - - public EncryptionService(@Value("${sshmanager.encryption-key}") String base64Key) { - this.keyBytes = Base64.getDecoder().decode(base64Key); - if (keyBytes.length != 32) { - throw new IllegalArgumentException("Encryption key must be 32 bytes (256 bits)"); - } - } - - public String encrypt(String plainText) { - if (plainText == null || plainText.isEmpty()) { - return null; - } - try { - byte[] iv = new byte[GCM_IV_LENGTH]; - new SecureRandom().nextBytes(iv); - - SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); - GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); - - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); - byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - - byte[] combined = new byte[iv.length + encrypted.length]; - System.arraycopy(iv, 0, combined, 0, iv.length); - System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); - - return Base64.getEncoder().encodeToString(combined); - } catch (Exception e) { - throw new RuntimeException("Encryption failed", e); - } - } - - public String decrypt(String encryptedText) { - if (encryptedText == null || encryptedText.isEmpty()) { - return null; - } - try { - byte[] combined = Base64.getDecoder().decode(encryptedText); - byte[] iv = new byte[GCM_IV_LENGTH]; - byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH]; - System.arraycopy(combined, 0, iv, 0, iv.length); - System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length); - - SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); - GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); - - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); - byte[] decrypted = cipher.doFinal(encrypted); - - return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { - throw new RuntimeException("Decryption failed", e); - } - } -} +package com.sshmanager.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +@Service +public class EncryptionService { + + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + private static final String ALGORITHM = "AES/GCM/NoPadding"; + + private final byte[] keyBytes; + + public EncryptionService(@Value("${sshmanager.encryption-key}") String base64Key) { + this.keyBytes = Base64.getDecoder().decode(base64Key); + if (keyBytes.length != 32) { + throw new IllegalArgumentException("Encryption key must be 32 bytes (256 bits)"); + } + } + + public String encrypt(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return null; + } + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + + return Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + throw new RuntimeException("Encryption failed", e); + } + } + + public String decrypt(String encryptedText) { + if (encryptedText == null || encryptedText.isEmpty()) { + return null; + } + try { + byte[] combined = Base64.getDecoder().decode(encryptedText); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, iv.length); + System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length); + + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + byte[] decrypted = cipher.doFinal(encrypted); + + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Decryption failed", e); + } + } +} diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index e166da4..ad860a6 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -1,188 +1,188 @@ -package com.sshmanager.service; - -import com.jcraft.jsch.ChannelSftp; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.Session; -import com.sshmanager.entity.Connection; -import org.springframework.stereotype.Service; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Vector; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -@Service -public class SftpService { - - public SftpSession connect(Connection conn, String password, String privateKey, String passphrase) - throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) { - session.setPassword(password); - } - session.connect(10000); - - ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); - channel.connect(5000); - - return new SftpSession(session, channel); - } - - public static class SftpSession { - private final Session session; - private final ChannelSftp channel; - - public SftpSession(Session session, ChannelSftp channel) { - this.session = session; - this.channel = channel; - } - - public ChannelSftp getChannel() { - return channel; - } - - public void disconnect() { - if (channel != null && channel.isConnected()) { - channel.disconnect(); - } - if (session != null && session.isConnected()) { - session.disconnect(); - } - } - - public boolean isConnected() { - return channel != null && channel.isConnected(); - } - } - - public static class FileInfo { - public String name; - public boolean directory; - public long size; - public long mtime; - - public FileInfo(String name, boolean directory, long size, long mtime) { - this.name = name; - this.directory = directory; - this.size = size; - this.mtime = mtime; - } - } - - public List listFiles(SftpSession sftpSession, String path) throws Exception { - Vector> entries = sftpSession.getChannel().ls(path); - List result = new ArrayList<>(); - for (Object obj : entries) { - ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; - String name = entry.getFilename(); - if (".".equals(name) || "..".equals(name)) continue; - result.add(new FileInfo( - name, - entry.getAttrs().isDir(), - entry.getAttrs().getSize(), - entry.getAttrs().getMTime() * 1000L - )); - } - return result; - } - - public byte[] download(SftpSession sftpSession, String remotePath) throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - sftpSession.getChannel().get(remotePath, out); - return out.toByteArray(); - } - - public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception { - sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath); - } - - public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { - if (isDir) { - sftpSession.getChannel().rmdir(remotePath); - } else { - sftpSession.getChannel().rm(remotePath); - } - } - - public void mkdir(SftpSession sftpSession, String remotePath) throws Exception { - sftpSession.getChannel().mkdir(remotePath); - } - - public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception { - sftpSession.getChannel().rename(oldPath, newPath); - } - - public String pwd(SftpSession sftpSession) throws Exception { - return sftpSession.getChannel().pwd(); - } - - public void cd(SftpSession sftpSession, String path) throws Exception { - sftpSession.getChannel().cd(path); - } - - /** - * Transfer a single file from source session to target session (streaming, no full file in memory). - * Fails if sourcePath is a directory. - */ - public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath) - throws Exception { - if (source.getChannel().stat(sourcePath).isDir()) { - throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported"); - } - final int pipeBufferSize = 65536; - PipedOutputStream pos = new PipedOutputStream(); - PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize); - - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future> putFuture = executor.submit(() -> { - try { - target.getChannel().put(pis, targetPath); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - source.getChannel().get(sourcePath, pos); - pos.close(); - putFuture.get(5, TimeUnit.MINUTES); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) { - throw (Exception) cause.getCause(); - } - if (cause instanceof Exception) { - throw (Exception) cause; - } - throw new RuntimeException(cause); - } catch (TimeoutException e) { - throw new RuntimeException("Transfer timeout", e); - } finally { - executor.shutdownNow(); - try { - pis.close(); - } catch (Exception ignored) { - } - } - } -} +package com.sshmanager.service; + +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.sshmanager.entity.Connection; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Vector; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Service +public class SftpService { + + public SftpSession connect(Connection conn, String password, String privateKey, String passphrase) + throws Exception { + JSch jsch = new JSch(); + + if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { + byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); + byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) + ? passphrase.getBytes(StandardCharsets.UTF_8) : null; + jsch.addIdentity("key", keyBytes, null, passphraseBytes); + } + + Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); + session.setConfig("StrictHostKeyChecking", "no"); + if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) { + session.setPassword(password); + } + session.connect(10000); + + ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); + channel.connect(5000); + + return new SftpSession(session, channel); + } + + public static class SftpSession { + private final Session session; + private final ChannelSftp channel; + + public SftpSession(Session session, ChannelSftp channel) { + this.session = session; + this.channel = channel; + } + + public ChannelSftp getChannel() { + return channel; + } + + public void disconnect() { + if (channel != null && channel.isConnected()) { + channel.disconnect(); + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + + public boolean isConnected() { + return channel != null && channel.isConnected(); + } + } + + public static class FileInfo { + public String name; + public boolean directory; + public long size; + public long mtime; + + public FileInfo(String name, boolean directory, long size, long mtime) { + this.name = name; + this.directory = directory; + this.size = size; + this.mtime = mtime; + } + } + + public List listFiles(SftpSession sftpSession, String path) throws Exception { + Vector> entries = sftpSession.getChannel().ls(path); + List result = new ArrayList<>(); + for (Object obj : entries) { + ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; + String name = entry.getFilename(); + if (".".equals(name) || "..".equals(name)) continue; + result.add(new FileInfo( + name, + entry.getAttrs().isDir(), + entry.getAttrs().getSize(), + entry.getAttrs().getMTime() * 1000L + )); + } + return result; + } + + public byte[] download(SftpSession sftpSession, String remotePath) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + sftpSession.getChannel().get(remotePath, out); + return out.toByteArray(); + } + + public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception { + sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath); + } + + public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { + if (isDir) { + sftpSession.getChannel().rmdir(remotePath); + } else { + sftpSession.getChannel().rm(remotePath); + } + } + + public void mkdir(SftpSession sftpSession, String remotePath) throws Exception { + sftpSession.getChannel().mkdir(remotePath); + } + + public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception { + sftpSession.getChannel().rename(oldPath, newPath); + } + + public String pwd(SftpSession sftpSession) throws Exception { + return sftpSession.getChannel().pwd(); + } + + public void cd(SftpSession sftpSession, String path) throws Exception { + sftpSession.getChannel().cd(path); + } + + /** + * Transfer a single file from source session to target session (streaming, no full file in memory). + * Fails if sourcePath is a directory. + */ + public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath) + throws Exception { + if (source.getChannel().stat(sourcePath).isDir()) { + throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported"); + } + final int pipeBufferSize = 65536; + PipedOutputStream pos = new PipedOutputStream(); + PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future> putFuture = executor.submit(() -> { + try { + target.getChannel().put(pis, targetPath); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + source.getChannel().get(sourcePath, pos); + pos.close(); + putFuture.get(5, TimeUnit.MINUTES); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) { + throw (Exception) cause.getCause(); + } + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw new RuntimeException(cause); + } catch (TimeoutException e) { + throw new RuntimeException("Transfer timeout", e); + } finally { + executor.shutdownNow(); + try { + pis.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/backend/src/main/java/com/sshmanager/service/SshService.java b/backend/src/main/java/com/sshmanager/service/SshService.java index 9ab77cc..1991e87 100644 --- a/backend/src/main/java/com/sshmanager/service/SshService.java +++ b/backend/src/main/java/com/sshmanager/service/SshService.java @@ -1,97 +1,97 @@ -package com.sshmanager.service; - -import com.jcraft.jsch.ChannelShell; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.Session; -import com.sshmanager.entity.Connection; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; - -@Service -public class SshService { - - public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase) - throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - - if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) { - session.setPassword(password); - } - - session.connect(10000); - - ChannelShell channel = (ChannelShell) session.openChannel("shell"); - channel.setPtyType("xterm"); - channel.connect(5000); - - PipedInputStream pipedIn = new PipedInputStream(); - PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn); - OutputStream channelIn = channel.getOutputStream(); - InputStream channelOut = channel.getInputStream(); - - new Thread(() -> { - try { - byte[] buf = new byte[1024]; - int n; - while ((n = pipedIn.read(buf)) > 0) { - channelIn.write(buf, 0, n); - channelIn.flush(); - } - } catch (Exception e) { - // Channel closed - } - }).start(); - - return new SshSession(session, channel, channelOut, pipeToChannel); - } - - public static class SshSession { - private final Session session; - private final ChannelShell channel; - private final InputStream outputStream; - private final OutputStream inputStream; - - public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) { - this.session = session; - this.channel = channel; - this.outputStream = outputStream; - this.inputStream = inputStream; - } - - public InputStream getOutputStream() { - return outputStream; - } - - public OutputStream getInputStream() { - return inputStream; - } - - public void disconnect() { - if (channel != null && channel.isConnected()) { - channel.disconnect(); - } - if (session != null && session.isConnected()) { - session.disconnect(); - } - } - - public boolean isConnected() { - return channel != null && channel.isConnected(); - } - } -} +package com.sshmanager.service; + +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.sshmanager.entity.Connection; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +@Service +public class SshService { + + public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase) + throws Exception { + JSch jsch = new JSch(); + + if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { + byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); + byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) + ? passphrase.getBytes(StandardCharsets.UTF_8) : null; + jsch.addIdentity("key", keyBytes, null, passphraseBytes); + } + + Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); + session.setConfig("StrictHostKeyChecking", "no"); + + if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) { + session.setPassword(password); + } + + session.connect(10000); + + ChannelShell channel = (ChannelShell) session.openChannel("shell"); + channel.setPtyType("xterm"); + channel.connect(5000); + + PipedInputStream pipedIn = new PipedInputStream(); + PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn); + OutputStream channelIn = channel.getOutputStream(); + InputStream channelOut = channel.getInputStream(); + + new Thread(() -> { + try { + byte[] buf = new byte[1024]; + int n; + while ((n = pipedIn.read(buf)) > 0) { + channelIn.write(buf, 0, n); + channelIn.flush(); + } + } catch (Exception e) { + // Channel closed + } + }).start(); + + return new SshSession(session, channel, channelOut, pipeToChannel); + } + + public static class SshSession { + private final Session session; + private final ChannelShell channel; + private final InputStream outputStream; + private final OutputStream inputStream; + + public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) { + this.session = session; + this.channel = channel; + this.outputStream = outputStream; + this.inputStream = inputStream; + } + + public InputStream getOutputStream() { + return outputStream; + } + + public OutputStream getInputStream() { + return inputStream; + } + + public void disconnect() { + if (channel != null && channel.isConnected()) { + channel.disconnect(); + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + + public boolean isConnected() { + return channel != null && channel.isConnected(); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 497ba32..6f9e2e3 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,27 +1,27 @@ -server: - port: 48080 - -spring: - datasource: - url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1 - driver-class-name: org.h2.Driver - username: sa - password: - h2: - console: - enabled: false - jpa: - hibernate: - ddl-auto: update - show-sql: false - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - open-in-view: false - -# Encryption key for connection passwords (base64, 32 bytes for AES-256) -sshmanager: - encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=} - jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production} - jwt-expiration-ms: 86400000 +server: + port: 48080 + +spring: + datasource: + url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: false + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + open-in-view: false + +# Encryption key for connection passwords (base64, 32 bytes for AES-256) +sshmanager: + encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=} + jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production} + jwt-expiration-ms: 86400000 diff --git a/design-system/MASTER.md b/design-system/MASTER.md index 564d9a4..29de62c 100644 --- a/design-system/MASTER.md +++ b/design-system/MASTER.md @@ -1,38 +1,38 @@ -# SSH 管理器设计系统 - -## 风格 - -- **产品类型**:管理后台 / 开发工具仪表盘 -- **主题**:深色、专业、终端风格 -- **布局**:侧边栏 + 主内容区 - -## 色彩 - -- 背景:slate-900 (#0f172a)、slate-800 -- 表面:slate-800、slate-700 -- 主文字:slate-100 (#f1f5f9) -- 次要文字:slate-400 -- 强调(成功/连接):emerald-500、cyan-500 -- 边框:slate-600、slate-700 - -## 字体 - -- 字体:Inter 或 system-ui -- 正文:最小 16px,行高 1.5 - -## 图标 - -- 仅使用 Lucide 图标,不使用 emoji -- 尺寸:统一 20px 或 24px - -## 交互 - -- 所有可点击元素使用 cursor-pointer -- transition-colors duration-200 -- 最小触控区域 44×44px - -## 无障碍 - -- 对比度 4.5:1 -- 可见焦点环 -- 仅图标按钮需设置 aria-label +# SSH 管理器设计系统 + +## 风格 + +- **产品类型**:管理后台 / 开发工具仪表盘 +- **主题**:深色、专业、终端风格 +- **布局**:侧边栏 + 主内容区 + +## 色彩 + +- 背景:slate-900 (#0f172a)、slate-800 +- 表面:slate-800、slate-700 +- 主文字:slate-100 (#f1f5f9) +- 次要文字:slate-400 +- 强调(成功/连接):emerald-500、cyan-500 +- 边框:slate-600、slate-700 + +## 字体 + +- 字体:Inter 或 system-ui +- 正文:最小 16px,行高 1.5 + +## 图标 + +- 仅使用 Lucide 图标,不使用 emoji +- 尺寸:统一 20px 或 24px + +## 交互 + +- 所有可点击元素使用 cursor-pointer +- transition-colors duration-200 +- 最小触控区域 44×44px + +## 无障碍 + +- 对比度 4.5:1 +- 可见焦点环 +- 仅图标按钮需设置 aria-label diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 481275b..7cf585d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -999,7 +999,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1478,7 +1477,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2110,7 +2108,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2443,7 +2440,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -2500,7 +2496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3056,7 +3051,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3152,7 +3146,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3234,7 +3227,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b..5eec88d 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index b861f12..d226b05 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,20 +1,20 @@ -import client from './client' - -export interface LoginRequest { - username: string - password: string -} - -export interface LoginResponse { - token: string - username: string - displayName: string -} - -export function login(data: LoginRequest) { - return client.post('/auth/login', data) -} - -export function getMe() { - return client.get<{ username: string; displayName: string }>('/auth/me') -} +import client from './client' + +export interface LoginRequest { + username: string + password: string +} + +export interface LoginResponse { + token: string + username: string + displayName: string +} + +export function login(data: LoginRequest) { + return client.post('/auth/login', data) +} + +export function getMe() { + return client.get<{ username: string; displayName: string }>('/auth/me') +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 58d6165..51d19f4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,29 +1,29 @@ -import axios from 'axios' - -const client = axios.create({ - baseURL: '/api', - headers: { - 'Content-Type': 'application/json', - }, -}) - -client.interceptors.request.use((config) => { - const token = localStorage.getItem('token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - -client.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('token') - window.location.href = '/login' - } - return Promise.reject(error) - } -) - -export default client +import axios from 'axios' + +const client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +client.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export default client diff --git a/frontend/src/api/connections.ts b/frontend/src/api/connections.ts index 63529f5..35a2820 100644 --- a/frontend/src/api/connections.ts +++ b/frontend/src/api/connections.ts @@ -1,45 +1,45 @@ -import client from './client' - -export type AuthType = 'PASSWORD' | 'PRIVATE_KEY' - -export interface Connection { - id: number - name: string - host: string - port: number - username: string - authType: AuthType - createdAt: string - updatedAt: string -} - -export interface ConnectionCreateRequest { - name: string - host: string - port?: number - username: string - authType?: AuthType - password?: string - privateKey?: string - passphrase?: string -} - -export function listConnections() { - return client.get('/connections') -} - -export function getConnection(id: number) { - return client.get(`/connections/${id}`) -} - -export function createConnection(data: ConnectionCreateRequest) { - return client.post('/connections', data) -} - -export function updateConnection(id: number, data: ConnectionCreateRequest) { - return client.put(`/connections/${id}`, data) -} - -export function deleteConnection(id: number) { - return client.delete(`/connections/${id}`) -} +import client from './client' + +export type AuthType = 'PASSWORD' | 'PRIVATE_KEY' + +export interface Connection { + id: number + name: string + host: string + port: number + username: string + authType: AuthType + createdAt: string + updatedAt: string +} + +export interface ConnectionCreateRequest { + name: string + host: string + port?: number + username: string + authType?: AuthType + password?: string + privateKey?: string + passphrase?: string +} + +export function listConnections() { + return client.get('/connections') +} + +export function getConnection(id: number) { + return client.get(`/connections/${id}`) +} + +export function createConnection(data: ConnectionCreateRequest) { + return client.post('/connections', data) +} + +export function updateConnection(id: number, data: ConnectionCreateRequest) { + return client.put(`/connections/${id}`, data) +} + +export function deleteConnection(id: number) { + return client.delete(`/connections/${id}`) +} diff --git a/frontend/src/api/sftp.ts b/frontend/src/api/sftp.ts index 5e95cd2..84f6c24 100644 --- a/frontend/src/api/sftp.ts +++ b/frontend/src/api/sftp.ts @@ -1,81 +1,81 @@ -import client from './client' - -export interface SftpFileInfo { - name: string - directory: boolean - size: number - mtime: number -} - -export function listFiles(connectionId: number, path: string) { - return client.get('/sftp/list', { - params: { connectionId, path: path || '.' }, - }) -} - -export function getPwd(connectionId: number) { - return client.get<{ path: string }>('/sftp/pwd', { - params: { connectionId }, - }) -} - -export async function downloadFile(connectionId: number, path: string) { - const token = localStorage.getItem('token') - const params = new URLSearchParams({ connectionId: String(connectionId), path }) - const res = await fetch(`/api/sftp/download?${params}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Download failed') - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = path.split('/').pop() || 'download' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) -} - -export function uploadFile(connectionId: number, path: string, file: File) { - const form = new FormData() - form.append('file', file) - return client.post('/sftp/upload', form, { - params: { connectionId, path }, - headers: { 'Content-Type': 'multipart/form-data' }, - }) -} - -export function deleteFile(connectionId: number, path: string, directory: boolean) { - return client.delete('/sftp/delete', { - params: { connectionId, path, directory }, - }) -} - -export function createDir(connectionId: number, path: string) { - return client.post('/sftp/mkdir', null, { - params: { connectionId, path }, - }) -} - -export function renameFile(connectionId: number, oldPath: string, newPath: string) { - return client.post('/sftp/rename', null, { - params: { connectionId, oldPath, newPath }, - }) -} - -export function transferRemote( - sourceConnectionId: number, - sourcePath: string, - targetConnectionId: number, - targetPath: string -) { - return client.post<{ message: string }>('/sftp/transfer-remote', null, { - params: { - sourceConnectionId, - sourcePath, - targetConnectionId, - targetPath, - }, - }) -} +import client from './client' + +export interface SftpFileInfo { + name: string + directory: boolean + size: number + mtime: number +} + +export function listFiles(connectionId: number, path: string) { + return client.get('/sftp/list', { + params: { connectionId, path: path || '.' }, + }) +} + +export function getPwd(connectionId: number) { + return client.get<{ path: string }>('/sftp/pwd', { + params: { connectionId }, + }) +} + +export async function downloadFile(connectionId: number, path: string) { + const token = localStorage.getItem('token') + const params = new URLSearchParams({ connectionId: String(connectionId), path }) + const res = await fetch(`/api/sftp/download?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Download failed') + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = path.split('/').pop() || 'download' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +export function uploadFile(connectionId: number, path: string, file: File) { + const form = new FormData() + form.append('file', file) + return client.post('/sftp/upload', form, { + params: { connectionId, path }, + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +export function deleteFile(connectionId: number, path: string, directory: boolean) { + return client.delete('/sftp/delete', { + params: { connectionId, path, directory }, + }) +} + +export function createDir(connectionId: number, path: string) { + return client.post('/sftp/mkdir', null, { + params: { connectionId, path }, + }) +} + +export function renameFile(connectionId: number, oldPath: string, newPath: string) { + return client.post('/sftp/rename', null, { + params: { connectionId, oldPath, newPath }, + }) +} + +export function transferRemote( + sourceConnectionId: number, + sourcePath: string, + targetConnectionId: number, + targetPath: string +) { + return client.post<{ message: string }>('/sftp/transfer-remote', null, { + params: { + sourceConnectionId, + sourcePath, + targetConnectionId, + targetPath, + }, + }) +} diff --git a/frontend/src/components/ConnectionForm.vue b/frontend/src/components/ConnectionForm.vue index 79a8efd..7dc145c 100644 --- a/frontend/src/components/ConnectionForm.vue +++ b/frontend/src/components/ConnectionForm.vue @@ -1,238 +1,238 @@ - - - - - - - - {{ isEdit ? '编辑连接' : '新建连接' }} - - - - - - - - 名称 - - - - - 主机 - - - - 端口 - - - - - 用户名 - - - - 认证方式 - - - - 密码 - - - - 私钥 - - - - - - 密码 {{ isEdit ? '(留空则不修改)' : '' }} - - - - - - 私钥 {{ isEdit ? '(留空则不修改)' : '' }} - - - 私钥口令(可选) - - - {{ error }} - - - 取消 - - - {{ isEdit ? '更新' : '创建' }} - - - - - - + + + + + + + + {{ isEdit ? '编辑连接' : '新建连接' }} + + + + + + + + 名称 + + + + + 主机 + + + + 端口 + + + + + 用户名 + + + + 认证方式 + + + + 密码 + + + + 私钥 + + + + + + 密码 {{ isEdit ? '(留空则不修改)' : '' }} + + + + + + 私钥 {{ isEdit ? '(留空则不修改)' : '' }} + + + 私钥口令(可选) + + + {{ error }} + + + 取消 + + + {{ isEdit ? '更新' : '创建' }} + + + + + + diff --git a/frontend/src/components/TerminalWidget.vue b/frontend/src/components/TerminalWidget.vue index 0767d26..8e14932 100644 --- a/frontend/src/components/TerminalWidget.vue +++ b/frontend/src/components/TerminalWidget.vue @@ -1,141 +1,141 @@ - - - - - - - 正在连接 SSH... - - - {{ errorMessage }} - - - - - - + + + + + + + 正在连接 SSH... + + + {{ errorMessage }} + + + + + + diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index d5cbbad..5d4124e 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -1,74 +1,74 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8af5443..dc50b98 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,63 +1,63 @@ -import { createRouter, createWebHistory } from 'vue-router' -import type { RouteRecordRaw } from 'vue-router' -import { useAuthStore } from '../stores/auth' - -const routes: RouteRecordRaw[] = [ - { - path: '/login', - name: 'Login', - component: () => import('../views/LoginView.vue'), - meta: { public: true }, - }, - { - path: '/', - component: () => import('../layouts/MainLayout.vue'), - meta: { requiresAuth: true }, - children: [ - { - path: '', - name: 'Home', - redirect: '/connections', - }, - { - path: 'connections', - name: 'Connections', - component: () => import('../views/ConnectionsView.vue'), - }, - { - path: 'terminal/:id', - name: 'Terminal', - component: () => import('../views/TerminalView.vue'), - }, - { - path: 'sftp/:id', - name: 'Sftp', - component: () => import('../views/SftpView.vue'), - }, - ], - }, -] - -const router = createRouter({ - history: createWebHistory(), - routes, -}) - -router.beforeEach(async (to, _from, next) => { - const authStore = useAuthStore() - if (to.meta.public) { - if (authStore.isAuthenticated && to.path === '/login') { - next('/connections') - } else { - next() - } - return - } - if (to.meta.requiresAuth && !authStore.isAuthenticated) { - next('/login') - return - } - next() -}) - -export default router +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/', + component: () => import('../layouts/MainLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Home', + redirect: '/connections', + }, + { + path: 'connections', + name: 'Connections', + component: () => import('../views/ConnectionsView.vue'), + }, + { + path: 'terminal/:id', + name: 'Terminal', + component: () => import('../views/TerminalView.vue'), + }, + { + path: 'sftp/:id', + name: 'Sftp', + component: () => import('../views/SftpView.vue'), + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach(async (to, _from, next) => { + const authStore = useAuthStore() + if (to.meta.public) { + if (authStore.isAuthenticated && to.path === '/login') { + next('/connections') + } else { + next() + } + return + } + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + return + } + next() +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 5e89fd0..2b0d353 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,42 +1,42 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import * as authApi from '../api/auth' - -export const useAuthStore = defineStore('auth', () => { - const token = ref(localStorage.getItem('token')) - const username = ref(null) - const displayName = ref(null) - - const isAuthenticated = computed(() => !!token.value) - - function setAuth(t: string, u: string, d: string) { - token.value = t - username.value = u - displayName.value = d || u - localStorage.setItem('token', t) - } - - function logout() { - token.value = null - username.value = null - displayName.value = null - localStorage.removeItem('token') - } - - async function fetchMe() { - const res = await authApi.getMe() - username.value = res.data.username - displayName.value = res.data.displayName || res.data.username - return res.data - } - - return { - token, - username, - displayName, - isAuthenticated, - setAuth, - logout, - fetchMe, - } -}) +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as authApi from '../api/auth' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token')) + const username = ref(null) + const displayName = ref(null) + + const isAuthenticated = computed(() => !!token.value) + + function setAuth(t: string, u: string, d: string) { + token.value = t + username.value = u + displayName.value = d || u + localStorage.setItem('token', t) + } + + function logout() { + token.value = null + username.value = null + displayName.value = null + localStorage.removeItem('token') + } + + async function fetchMe() { + const res = await authApi.getMe() + username.value = res.data.username + displayName.value = res.data.displayName || res.data.username + return res.data + } + + return { + token, + username, + displayName, + isAuthenticated, + setAuth, + logout, + fetchMe, + } +}) diff --git a/frontend/src/stores/connections.ts b/frontend/src/stores/connections.ts index 1d964b1..6b6169a 100644 --- a/frontend/src/stores/connections.ts +++ b/frontend/src/stores/connections.ts @@ -1,45 +1,45 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import type { Connection, ConnectionCreateRequest } from '../api/connections' -import * as connectionsApi from '../api/connections' - -export const useConnectionsStore = defineStore('connections', () => { - const connections = ref([]) - - async function fetchConnections() { - const res = await connectionsApi.listConnections() - connections.value = res.data - return res.data - } - - async function createConnection(data: ConnectionCreateRequest) { - const res = await connectionsApi.createConnection(data) - connections.value.unshift(res.data) - return res.data - } - - async function updateConnection(id: number, data: ConnectionCreateRequest) { - const res = await connectionsApi.updateConnection(id, data) - const idx = connections.value.findIndex((c) => c.id === id) - if (idx >= 0) connections.value[idx] = res.data - return res.data - } - - async function deleteConnection(id: number) { - await connectionsApi.deleteConnection(id) - connections.value = connections.value.filter((c) => c.id !== id) - } - - function getConnection(id: number): Connection | undefined { - return connections.value.find((c) => c.id === id) - } - - return { - connections, - fetchConnections, - createConnection, - updateConnection, - deleteConnection, - getConnection, - } -}) +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Connection, ConnectionCreateRequest } from '../api/connections' +import * as connectionsApi from '../api/connections' + +export const useConnectionsStore = defineStore('connections', () => { + const connections = ref([]) + + async function fetchConnections() { + const res = await connectionsApi.listConnections() + connections.value = res.data + return res.data + } + + async function createConnection(data: ConnectionCreateRequest) { + const res = await connectionsApi.createConnection(data) + connections.value.unshift(res.data) + return res.data + } + + async function updateConnection(id: number, data: ConnectionCreateRequest) { + const res = await connectionsApi.updateConnection(id, data) + const idx = connections.value.findIndex((c) => c.id === id) + if (idx >= 0) connections.value[idx] = res.data + return res.data + } + + async function deleteConnection(id: number) { + await connectionsApi.deleteConnection(id) + connections.value = connections.value.filter((c) => c.id !== id) + } + + function getConnection(id: number): Connection | undefined { + return connections.value.find((c) => c.id === id) + } + + return { + connections, + fetchConnections, + createConnection, + updateConnection, + deleteConnection, + getConnection, + } +}) diff --git a/frontend/src/views/ConnectionsView.vue b/frontend/src/views/ConnectionsView.vue index c2c343e..97f39c0 100644 --- a/frontend/src/views/ConnectionsView.vue +++ b/frontend/src/views/ConnectionsView.vue @@ -1,156 +1,156 @@ - - - - - - 连接列表 - - - 添加连接 - - - - - - 暂无连接 - - 添加第一个连接 - - - - - - - - - - - - {{ conn.name }} - {{ conn.username }}@{{ conn.host }}:{{ conn.port }} - - - - - - - - - - - - - - {{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }} - - - - - 终端 - - - - 文件 - - - - - - - - + + + + + + 连接列表 + + + 添加连接 + + + + + + 暂无连接 + + 添加第一个连接 + + + + + + + + + + + + {{ conn.name }} + {{ conn.username }}@{{ conn.host }}:{{ conn.port }} + + + + + + + + + + + + + + {{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }} + + + + + 终端 + + + + 文件 + + + + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index d26742a..29905d7 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,88 +1,88 @@ - - - - - - - - - - - SSH 管理器 - - 登录您的账户 - - - - - 用户名 - - - - - - 密码 - - - - {{ error }} - - {{ loading ? '登录中...' : '登录' }} - - - - 默认:admin / admin123 - - - - - + + + + + + + + + + + SSH 管理器 + + 登录您的账户 + + + + + 用户名 + + + + + + 密码 + + + + {{ error }} + + {{ loading ? '登录中...' : '登录' }} + + + + 默认:admin / admin123 + + + + + diff --git a/frontend/src/views/SftpView.vue b/frontend/src/views/SftpView.vue index fb771e0..d567eda 100644 --- a/frontend/src/views/SftpView.vue +++ b/frontend/src/views/SftpView.vue @@ -1,436 +1,436 @@ - - - - - - - - - - {{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }} - - - - - - - - - / - - - - - {{ part || '/' }} - - - - - - - - - - - - - - - - - - {{ error }} - - - 加载中... - - - - - - .. - - - - {{ file.name }} - - {{ formatSize(file.size) }} - - {{ formatDate(file.mtime) }} - - - - - - - - - - - - - - 空目录 - - - - - - - - - - 复制到远程 - - - 文件:{{ transferFile.name }} - - - - 目标连接 - - - {{ c.name }} ({{ c.username }}@{{ c.host }}) - - - - 暂无其他连接,请先在连接管理中添加 - - - - 目标路径(目录以 / 结尾) - - - - {{ transferError }} - - - 取消 - - - {{ transferring ? '传输中...' : '确定' }} - - - - - - - + + + + + + + + + + {{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }} + + + + + + + + + / + + + + + {{ part || '/' }} + + + + + + + + + + + + + + + + + + {{ error }} + + + 加载中... + + + + + + .. + + + + {{ file.name }} + + {{ formatSize(file.size) }} + + {{ formatDate(file.mtime) }} + + + + + + + + + + + + + + 空目录 + + + + + + + + + + 复制到远程 + + + 文件:{{ transferFile.name }} + + + + 目标连接 + + + {{ c.name }} ({{ c.username }}@{{ c.host }}) + + + + 暂无其他连接,请先在连接管理中添加 + + + + 目标路径(目录以 / 结尾) + + + + {{ transferError }} + + + 取消 + + + {{ transferring ? '传输中...' : '确定' }} + + + + + + + diff --git a/frontend/src/views/TerminalView.vue b/frontend/src/views/TerminalView.vue index 7c38472..a3e18c7 100644 --- a/frontend/src/views/TerminalView.vue +++ b/frontend/src/views/TerminalView.vue @@ -1,46 +1,46 @@ - - - - - - - - - - {{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }} - - - - - - 加载中... - - - - + + + + + + + + + + {{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }} + + + + + + 加载中... + + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 5b3aa9d..e8d8230 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,18 +1,18 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{vue,js,ts,jsx,tsx}", - ], - darkMode: 'class', - theme: { - extend: { - colors: { - slate: { - 850: '#172033', - } - } - }, - }, - plugins: [], -} +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + darkMode: 'class', + theme: { + extend: { + colors: { + slate: { + 850: '#172033', + } + } + }, + }, + plugins: [], +}
{{ error }}
暂无连接
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
- 默认:admin / admin123 -
+ 默认:admin / admin123 +
- 文件:{{ transferFile.name }} -
- 暂无其他连接,请先在连接管理中添加 -
{{ transferError }}
+ 文件:{{ transferFile.name }} +
+ 暂无其他连接,请先在连接管理中添加 +