Initial commit: SSH Manager (backend + frontend)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +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
|
||||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +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 仅允许前端来源
|
||||||
92
backend/pom.xml
Normal file
92
backend/pom.xml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>2.7.18</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.sshmanager</groupId>
|
||||||
|
<artifactId>ssh-manager-backend</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>ssh-manager-backend</name>
|
||||||
|
<description>Web SSH Manager Backend</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>1.8</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.jcraft</groupId>
|
||||||
|
<artifactId>jsch</artifactId>
|
||||||
|
<version>0.1.55</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<String, String> body = new HashMap<>();
|
||||||
|
body.put("message", "Unauthorized");
|
||||||
|
objectMapper.writeValue(response.getOutputStream(), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<String, Object> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<String, String> 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<String, String> 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<String, Object> data = new HashMap<>();
|
||||||
|
data.put("username", user.getUsername());
|
||||||
|
data.put("displayName", user.getDisplayName());
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<ConnectionDto>> list(Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(connectionService.listByUserId(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ConnectionDto> get(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(connectionService.getById(id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ConnectionDto> create(@RequestBody ConnectionCreateRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(connectionService.create(request, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ConnectionDto> 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<Map<String, String>> delete(@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
connectionService.delete(id, userId);
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Deleted");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<String, SftpService.SftpSession> 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<SftpFileInfo>> list(
|
||||||
|
@RequestParam Long connectionId,
|
||||||
|
@RequestParam(required = false, defaultValue = ".") String path,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
|
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
||||||
|
List<SftpFileInfo> dtos = files.stream()
|
||||||
|
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pwd")
|
||||||
|
public ResponseEntity<Map<String, String>> pwd(
|
||||||
|
@RequestParam Long connectionId,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
|
String pwd = sftpService.pwd(session);
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
result.put("path", pwd);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download")
|
||||||
|
public ResponseEntity<byte[]> 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<Map<String, String>> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Uploaded");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.status(500).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/delete")
|
||||||
|
public ResponseEntity<Map<String, String>> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Deleted");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.status(500).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mkdir")
|
||||||
|
public ResponseEntity<Map<String, String>> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Created");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.status(500).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rename")
|
||||||
|
public ResponseEntity<Map<String, String>> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Renamed");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.status(500).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/transfer-remote")
|
||||||
|
public ResponseEntity<Map<String, String>> 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<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "sourcePath is required");
|
||||||
|
return ResponseEntity.badRequest().body(err);
|
||||||
|
}
|
||||||
|
if (targetPath == null || targetPath.trim().isEmpty()) {
|
||||||
|
Map<String, String> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Transferred");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
|
||||||
|
return ResponseEntity.status(500).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/disconnect")
|
||||||
|
public ResponseEntity<Map<String, String>> 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<String, String> result = new HashMap<>();
|
||||||
|
result.put("message", "Disconnected");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<String, SshService.SshSession> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
29
backend/src/main/java/com/sshmanager/dto/ConnectionDto.java
Normal file
29
backend/src/main/java/com/sshmanager/dto/ConnectionDto.java
Normal file
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class LoginRequest {
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
14
backend/src/main/java/com/sshmanager/dto/LoginResponse.java
Normal file
14
backend/src/main/java/com/sshmanager/dto/LoginResponse.java
Normal file
@@ -0,0 +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;
|
||||||
|
}
|
||||||
15
backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java
Normal file
15
backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java
Normal file
@@ -0,0 +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;
|
||||||
|
}
|
||||||
59
backend/src/main/java/com/sshmanager/entity/Connection.java
Normal file
59
backend/src/main/java/com/sshmanager/entity/Connection.java
Normal file
@@ -0,0 +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();
|
||||||
|
}
|
||||||
35
backend/src/main/java/com/sshmanager/entity/User.java
Normal file
35
backend/src/main/java/com/sshmanager/entity/User.java
Normal file
@@ -0,0 +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();
|
||||||
|
}
|
||||||
@@ -0,0 +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<Connection, Long> {
|
||||||
|
|
||||||
|
List<Connection> findByUserIdOrderByUpdatedAtDesc(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +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<User, Long> {
|
||||||
|
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
}
|
||||||
@@ -0,0 +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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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<ConnectionDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
@@ -0,0 +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<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||||
|
Vector<?> entries = sftpSession.getChannel().ls(path);
|
||||||
|
List<FileInfo> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
@@ -0,0 +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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/src/main/resources/application.yml
Normal file
27
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
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
|
||||||
38
design-system/MASTER.md
Normal file
38
design-system/MASTER.md
Normal file
@@ -0,0 +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
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
本模板用于在 Vite 中基于 Vue 3 与 TypeScript 进行开发。模板使用 Vue 3 的 `<script setup>` 单文件组件,可参阅 [script setup 文档](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) 了解更多。
|
||||||
|
|
||||||
|
推荐的项目配置与 IDE 支持请参考 [Vue 文档 TypeScript 指南](https://vuejs.org/guide/typescript/overview.html#project-setup)。
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SSH 管理器</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3384
frontend/package-lock.json
generated
Normal file
3384
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/addon-attach": "^0.12.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"lucide-vue-next": "^0.563.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^5.0.2",
|
||||||
|
"xterm": "^5.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
6
frontend/src/App.vue
Normal file
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
20
frontend/src/api/auth.ts
Normal file
20
frontend/src/api/auth.ts
Normal file
@@ -0,0 +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<LoginResponse>('/auth/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMe() {
|
||||||
|
return client.get<{ username: string; displayName: string }>('/auth/me')
|
||||||
|
}
|
||||||
29
frontend/src/api/client.ts
Normal file
29
frontend/src/api/client.ts
Normal file
@@ -0,0 +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
|
||||||
45
frontend/src/api/connections.ts
Normal file
45
frontend/src/api/connections.ts
Normal file
@@ -0,0 +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<Connection[]>('/connections')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnection(id: number) {
|
||||||
|
return client.get<Connection>(`/connections/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnection(data: ConnectionCreateRequest) {
|
||||||
|
return client.post<Connection>('/connections', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConnection(id: number, data: ConnectionCreateRequest) {
|
||||||
|
return client.put<Connection>(`/connections/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteConnection(id: number) {
|
||||||
|
return client.delete(`/connections/${id}`)
|
||||||
|
}
|
||||||
81
frontend/src/api/sftp.ts
Normal file
81
frontend/src/api/sftp.ts
Normal file
@@ -0,0 +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<SftpFileInfo[]>('/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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
238
frontend/src/components/ConnectionForm.vue
Normal file
238
frontend/src/components/ConnectionForm.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { Connection, ConnectionCreateRequest, AuthType } from '../api/connections'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
connection: Connection | null
|
||||||
|
onSave?: (data: ConnectionCreateRequest) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [data: ConnectionCreateRequest]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const host = ref('')
|
||||||
|
const port = ref(22)
|
||||||
|
const username = ref('')
|
||||||
|
const authType = ref<AuthType>('PASSWORD')
|
||||||
|
const password = ref('')
|
||||||
|
const privateKey = ref('')
|
||||||
|
const passphrase = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.connection)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.connection,
|
||||||
|
(c) => {
|
||||||
|
if (c) {
|
||||||
|
name.value = c.name
|
||||||
|
host.value = c.host
|
||||||
|
port.value = c.port
|
||||||
|
username.value = c.username
|
||||||
|
authType.value = c.authType
|
||||||
|
password.value = ''
|
||||||
|
privateKey.value = ''
|
||||||
|
passphrase.value = ''
|
||||||
|
} else {
|
||||||
|
name.value = ''
|
||||||
|
host.value = ''
|
||||||
|
port.value = 22
|
||||||
|
username.value = ''
|
||||||
|
authType.value = 'PASSWORD'
|
||||||
|
password.value = ''
|
||||||
|
privateKey.value = ''
|
||||||
|
passphrase.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
error.value = '请填写名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!host.value.trim()) {
|
||||||
|
error.value = '请填写主机'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!username.value.trim()) {
|
||||||
|
error.value = '请填写用户名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
|
||||||
|
error.value = '请填写密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
|
||||||
|
error.value = '请填写私钥'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const data: ConnectionCreateRequest = {
|
||||||
|
name: name.value.trim(),
|
||||||
|
host: host.value.trim(),
|
||||||
|
port: port.value,
|
||||||
|
username: username.value.trim(),
|
||||||
|
authType: authType.value,
|
||||||
|
}
|
||||||
|
if (authType.value === 'PASSWORD' && password.value) {
|
||||||
|
data.password = password.value
|
||||||
|
}
|
||||||
|
if (authType.value === 'PRIVATE_KEY') {
|
||||||
|
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
|
||||||
|
if (passphrase.value) data.passphrase = passphrase.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (props.onSave) {
|
||||||
|
await props.onSave(data)
|
||||||
|
} else {
|
||||||
|
emit('save', data)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { message?: string } } }
|
||||||
|
error.value = err.response?.data?.message || '保存失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-lg bg-slate-800 rounded-xl border border-slate-700 shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="form-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<h2 id="form-title" class="text-lg font-semibold text-slate-100">
|
||||||
|
{{ isEdit ? '编辑连接' : '新建连接' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="emit('close')"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="handleSubmit" class="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-slate-300 mb-1">名称</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
placeholder="我的服务器"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label for="host" class="block text-sm font-medium text-slate-300 mb-1">主机</label>
|
||||||
|
<input
|
||||||
|
id="host"
|
||||||
|
v-model="host"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
||||||
|
<input
|
||||||
|
id="port"
|
||||||
|
v-model.number="port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">用户名</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input v-model="authType" type="radio" value="PASSWORD" class="rounded" />
|
||||||
|
<span class="text-slate-300">密码</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input v-model="authType" type="radio" value="PRIVATE_KEY" class="rounded" />
|
||||||
|
<span class="text-slate-300">私钥</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="authType === 'PASSWORD'">
|
||||||
|
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
密码 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
:placeholder="isEdit ? '••••••••' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
|
||||||
|
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="privateKey"
|
||||||
|
v-model="privateKey"
|
||||||
|
rows="6"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
></textarea>
|
||||||
|
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
||||||
|
<input
|
||||||
|
id="passphrase"
|
||||||
|
v-model="passphrase"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="emit('close')"
|
||||||
|
class="px-4 py-2 rounded-lg text-slate-300 hover:bg-slate-700 transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ isEdit ? '更新' : '创建' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
141
frontend/src/components/TerminalWidget.vue
Normal file
141
frontend/src/components/TerminalWidget.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Terminal } from 'xterm'
|
||||||
|
import { AttachAddon } from '@xterm/addon-attach'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
connectionId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
let term: Terminal | null = null
|
||||||
|
let fitAddon: FitAddon | null = null
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function getWsUrl(): string {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const token = authStore.token
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Not authenticated')
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (resizeObserver && containerRef.value) {
|
||||||
|
resizeObserver.unobserve(containerRef.value)
|
||||||
|
}
|
||||||
|
resizeObserver = null
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
if (term) {
|
||||||
|
term.dispose()
|
||||||
|
term = null
|
||||||
|
}
|
||||||
|
fitAddon = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.token) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = '未登录'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containerRef.value) return
|
||||||
|
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: {
|
||||||
|
background: '#0f172a',
|
||||||
|
foreground: '#f1f5f9',
|
||||||
|
cursor: '#22d3ee',
|
||||||
|
cursorAccent: '#0f172a',
|
||||||
|
selectionBackground: 'rgba(34, 211, 238, 0.3)',
|
||||||
|
},
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
term.open(containerRef.value)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(getWsUrl())
|
||||||
|
} catch (e) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : '连接失败'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.value = 'connected'
|
||||||
|
const attachAddon = new AttachAddon(ws!)
|
||||||
|
term!.loadAddon(attachAddon)
|
||||||
|
fitAddon?.fit()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (status.value === 'connecting') {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = 'WebSocket 连接失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (status.value === 'connected') {
|
||||||
|
term?.writeln('\r\n[连接已关闭]')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
fitAddon?.fit()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(containerRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="flex-1 min-h-0 p-4 xterm-container"
|
||||||
|
:class="{ 'flex items-center justify-center': status !== 'connected' }"
|
||||||
|
style="min-height: 300px"
|
||||||
|
>
|
||||||
|
<div v-if="status === 'connecting'" class="text-slate-400">
|
||||||
|
正在连接 SSH...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="status === 'error'" class="text-red-400">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.xterm-container .xterm {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.xterm-container .xterm-viewport {
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/layouts/MainLayout.vue
Normal file
74
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import { Server, LogOut, Menu, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
connectionsStore.fetchConnections().catch(() => {})
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebarOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen bg-slate-900">
|
||||||
|
<button
|
||||||
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
|
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
|
||||||
|
aria-label="切换侧边栏"
|
||||||
|
>
|
||||||
|
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
|
||||||
|
<X v-else class="w-6 h-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<aside
|
||||||
|
:class="[
|
||||||
|
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
|
||||||
|
'fixed lg:static inset-y-0 left-0',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="p-4 border-b border-slate-700">
|
||||||
|
<h1 class="text-lg font-semibold text-slate-100">SSH 管理器</h1>
|
||||||
|
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
||||||
|
<RouterLink
|
||||||
|
to="/connections"
|
||||||
|
@click="closeSidebar"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||||
|
aria-label="连接列表"
|
||||||
|
>
|
||||||
|
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>连接列表</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<div class="p-4 border-t border-slate-700">
|
||||||
|
<button
|
||||||
|
@click="authStore.logout(); $router.push('/login')"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
|
aria-label="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut class="w-5 h-5" aria-hidden="true" />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
v-if="sidebarOpen"
|
||||||
|
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
/>
|
||||||
|
<main class="flex-1 overflow-auto min-w-0">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
63
frontend/src/router/index.ts
Normal file
63
frontend/src/router/index.ts
Normal file
@@ -0,0 +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
|
||||||
42
frontend/src/stores/auth.ts
Normal file
42
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +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<string | null>(localStorage.getItem('token'))
|
||||||
|
const username = ref<string | null>(null)
|
||||||
|
const displayName = ref<string | null>(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,
|
||||||
|
}
|
||||||
|
})
|
||||||
45
frontend/src/stores/connections.ts
Normal file
45
frontend/src/stores/connections.ts
Normal file
@@ -0,0 +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<Connection[]>([])
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
19
frontend/src/style.css
Normal file
19
frontend/src/style.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-slate-900 text-slate-100 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
frontend/src/views/ConnectionsView.vue
Normal file
156
frontend/src/views/ConnectionsView.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||||
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Plus,
|
||||||
|
Terminal,
|
||||||
|
FolderOpen,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useConnectionsStore()
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingConn = ref<Connection | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => store.fetchConnections())
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingConn.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(conn: Connection) {
|
||||||
|
editingConn.value = conn
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
showForm.value = false
|
||||||
|
editingConn.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(data: ConnectionCreateRequest) {
|
||||||
|
if (editingConn.value) {
|
||||||
|
await store.updateConnection(editingConn.value.id, data)
|
||||||
|
} else {
|
||||||
|
await store.createConnection(data)
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(conn: Connection) {
|
||||||
|
if (!confirm(`确定删除连接「${conn.name}」?`)) return
|
||||||
|
await store.deleteConnection(conn.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerminal(conn: Connection) {
|
||||||
|
router.push(`/terminal/${conn.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSftp(conn: Connection) {
|
||||||
|
router.push(`/sftp/${conn.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
|
||||||
|
<button
|
||||||
|
@click="openCreate"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="添加连接"
|
||||||
|
>
|
||||||
|
<Plus class="w-5 h-5" aria-hidden="true" />
|
||||||
|
添加连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
|
||||||
|
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||||
|
<p class="text-slate-400 mb-4">暂无连接</p>
|
||||||
|
<button
|
||||||
|
@click="openCreate"
|
||||||
|
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
添加第一个连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="conn in store.connections"
|
||||||
|
:key="conn.id"
|
||||||
|
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||||
|
<Server class="w-5 h-5 text-cyan-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-slate-100">{{ conn.name }}</h3>
|
||||||
|
<p class="text-sm text-slate-400">{{ conn.username }}@{{ conn.host }}:{{ conn.port }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="openEdit(conn)"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="编辑"
|
||||||
|
>
|
||||||
|
<Pencil class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleDelete(conn)"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="删除"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<component
|
||||||
|
:is="conn.authType === 'PRIVATE_KEY' ? Key : Lock"
|
||||||
|
class="w-4 h-4 text-slate-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="openTerminal(conn)"
|
||||||
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||||
|
终端
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="openSftp(conn)"
|
||||||
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||||
|
文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectionForm
|
||||||
|
v-if="showForm"
|
||||||
|
:connection="editingConn"
|
||||||
|
:on-save="handleSave"
|
||||||
|
@close="closeForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
88
frontend/src/views/LoginView.vue
Normal file
88
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import * as authApi from '../api/auth'
|
||||||
|
import { LogIn } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.login({ username: username.value, password: password.value })
|
||||||
|
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||||
|
router.push('/connections')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||||
|
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-slate-900 px-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="bg-slate-800 rounded-xl shadow-xl border border-slate-700 p-8">
|
||||||
|
<div class="flex items-center gap-2 mb-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-cyan-600 flex items-center justify-center">
|
||||||
|
<LogIn class="w-6 h-6 text-white" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-semibold text-slate-100">SSH 管理器</h1>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg text-slate-300 mb-6">登录您的账户</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full py-2.5 px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-800"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-sm text-slate-500">
|
||||||
|
默认:admin / admin123
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
436
frontend/src/views/SftpView.vue
Normal file
436
frontend/src/views/SftpView.vue
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import * as sftpApi from '../api/sftp'
|
||||||
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
FolderOpen,
|
||||||
|
File,
|
||||||
|
Upload,
|
||||||
|
FolderPlus,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useConnectionsStore()
|
||||||
|
|
||||||
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
|
const conn = ref(store.getConnection(connectionId.value))
|
||||||
|
|
||||||
|
const currentPath = ref('.')
|
||||||
|
const pathParts = ref<string[]>([])
|
||||||
|
const files = ref<SftpFileInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
const selectedFile = ref<string | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const showTransferModal = ref(false)
|
||||||
|
const transferFile = ref<SftpFileInfo | null>(null)
|
||||||
|
const transferTargetConnectionId = ref<number | null>(null)
|
||||||
|
const transferTargetPath = ref('')
|
||||||
|
const transferring = ref(false)
|
||||||
|
const transferError = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
conn.value = store.getConnection(connectionId.value)
|
||||||
|
if (!conn.value) {
|
||||||
|
store.fetchConnections().then(() => {
|
||||||
|
conn.value = store.getConnection(connectionId.value)
|
||||||
|
initPath()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
initPath()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function initPath() {
|
||||||
|
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||||
|
const p = res.data.path || '/'
|
||||||
|
currentPath.value = p || '.'
|
||||||
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
|
loadPath()
|
||||||
|
}).catch(() => {
|
||||||
|
currentPath.value = '.'
|
||||||
|
pathParts.value = []
|
||||||
|
loadPath()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPath() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
sftpApi
|
||||||
|
.listFiles(connectionId.value, currentPath.value)
|
||||||
|
.then((res) => {
|
||||||
|
files.value = res.data.sort((a, b) => {
|
||||||
|
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
error.value = '获取文件列表失败'
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToDir(name: string) {
|
||||||
|
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||||
|
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||||
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToIndex(i: number) {
|
||||||
|
if (i < 0) {
|
||||||
|
currentPath.value = '.'
|
||||||
|
} else {
|
||||||
|
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||||
|
}
|
||||||
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goUp() {
|
||||||
|
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parts = currentPath.value.split('/').filter(Boolean)
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
currentPath.value = '/'
|
||||||
|
pathParts.value = ['']
|
||||||
|
} else {
|
||||||
|
parts.pop()
|
||||||
|
currentPath.value = '/' + parts.join('/')
|
||||||
|
pathParts.value = parts
|
||||||
|
}
|
||||||
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileClick(file: SftpFileInfo) {
|
||||||
|
if (file.directory) {
|
||||||
|
navigateToDir(file.name)
|
||||||
|
} else {
|
||||||
|
selectedFile.value = file.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload(file: SftpFileInfo) {
|
||||||
|
if (file.directory) return
|
||||||
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
|
const path = base ? base + '/' + file.name : file.name
|
||||||
|
sftpApi.downloadFile(connectionId.value, path).catch(() => {
|
||||||
|
error.value = '下载失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const selected = input.files
|
||||||
|
if (!selected?.length) return
|
||||||
|
uploading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < selected.length; i++) {
|
||||||
|
await sftpApi.uploadFile(connectionId.value, path, selected[i])
|
||||||
|
}
|
||||||
|
loadPath()
|
||||||
|
} catch {
|
||||||
|
error.value = '上传失败'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMkdir() {
|
||||||
|
const name = prompt('文件夹名称:')
|
||||||
|
if (!name?.trim()) return
|
||||||
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
|
const path = base ? base + '/' + name : name
|
||||||
|
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
|
||||||
|
error.value = '创建文件夹失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(file: SftpFileInfo) {
|
||||||
|
if (!confirm(`确定删除「${file.name}」?`)) return
|
||||||
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
|
const path = base ? base + '/' + file.name : file.name
|
||||||
|
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
|
||||||
|
error.value = '删除失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConnectionOptions = computed(() => {
|
||||||
|
const list = store.connections.filter((c) => c.id !== connectionId.value)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openTransferModal(file: SftpFileInfo) {
|
||||||
|
if (file.directory) return
|
||||||
|
if (store.connections.length === 0) await store.fetchConnections()
|
||||||
|
transferFile.value = file
|
||||||
|
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
|
||||||
|
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
|
||||||
|
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
|
||||||
|
transferError.value = ''
|
||||||
|
showTransferModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTransferModal() {
|
||||||
|
showTransferModal.value = false
|
||||||
|
transferFile.value = null
|
||||||
|
transferTargetConnectionId.value = null
|
||||||
|
transferTargetPath.value = ''
|
||||||
|
transferError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTransfer() {
|
||||||
|
const file = transferFile.value
|
||||||
|
const targetId = transferTargetConnectionId.value
|
||||||
|
if (!file || targetId == null) return
|
||||||
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
|
const sourcePath = base ? base + '/' + file.name : file.name
|
||||||
|
let targetPath = transferTargetPath.value.trim()
|
||||||
|
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
||||||
|
transferring.value = true
|
||||||
|
transferError.value = ''
|
||||||
|
try {
|
||||||
|
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
|
||||||
|
loadPath()
|
||||||
|
closeTransferModal()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const res = err as { response?: { data?: { error?: string } } }
|
||||||
|
transferError.value = res?.response?.data?.error ?? '传输失败'
|
||||||
|
} finally {
|
||||||
|
transferring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||||
|
<button
|
||||||
|
@click="router.push('/connections')"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="返回"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">
|
||||||
|
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto p-4">
|
||||||
|
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
||||||
|
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
@click="navigateToIndex(-1)"
|
||||||
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
<template v-for="(part, i) in pathParts" :key="i">
|
||||||
|
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
@click="navigateToIndex(i)"
|
||||||
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
||||||
|
>
|
||||||
|
{{ part || '/' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
@click="triggerUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||||
|
aria-label="上传"
|
||||||
|
>
|
||||||
|
<Upload class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleMkdir"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="新建文件夹"
|
||||||
|
>
|
||||||
|
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="loadPath"
|
||||||
|
:disabled="loading"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||||
|
aria-label="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="divide-y divide-slate-700">
|
||||||
|
<button
|
||||||
|
v-if="currentPath !== '.' && pathParts.length > 1"
|
||||||
|
@click="goUp"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||||
|
<span class="text-slate-400">..</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.name"
|
||||||
|
@click="handleFileClick(file)"
|
||||||
|
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="file.directory ? FolderOpen : File"
|
||||||
|
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
||||||
|
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
||||||
|
{{ formatSize(file.size) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
||||||
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
v-if="!file.directory"
|
||||||
|
@click.stop="handleDownload(file)"
|
||||||
|
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="下载"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!file.directory"
|
||||||
|
@click.stop="openTransferModal(file)"
|
||||||
|
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="复制到远程"
|
||||||
|
>
|
||||||
|
<Copy class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="handleDelete(file)"
|
||||||
|
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="删除"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||||
|
空目录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showTransferModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="transfer-modal-title"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
|
||||||
|
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
|
||||||
|
复制到远程
|
||||||
|
</h3>
|
||||||
|
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
|
||||||
|
文件:{{ transferFile.name }}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
|
||||||
|
<select
|
||||||
|
id="transfer-target-conn"
|
||||||
|
v-model.number="transferTargetConnectionId"
|
||||||
|
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
>
|
||||||
|
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
|
||||||
|
{{ c.name }} ({{ c.username }}@{{ c.host }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
|
||||||
|
暂无其他连接,请先在连接管理中添加
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径(目录以 / 结尾)</label>
|
||||||
|
<input
|
||||||
|
id="transfer-target-path"
|
||||||
|
v-model="transferTargetPath"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
placeholder="/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeTransferModal"
|
||||||
|
:disabled="transferring"
|
||||||
|
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="submitTransfer"
|
||||||
|
:disabled="transferring || transferTargetConnectionId == null"
|
||||||
|
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ transferring ? '传输中...' : '确定' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
46
frontend/src/views/TerminalView.vue
Normal file
46
frontend/src/views/TerminalView.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||||
|
import { ArrowLeft } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useConnectionsStore()
|
||||||
|
|
||||||
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
|
const conn = ref(store.getConnection(connectionId.value))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
conn.value = store.getConnection(connectionId.value)
|
||||||
|
if (!conn.value) {
|
||||||
|
store.fetchConnections().then(() => {
|
||||||
|
conn.value = store.getConnection(connectionId.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||||
|
<button
|
||||||
|
@click="router.push('/connections')"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="返回"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">
|
||||||
|
{{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0 p-4">
|
||||||
|
<TerminalWidget v-if="conn" :connection-id="conn.id" />
|
||||||
|
<div v-else class="flex items-center justify-center h-64 text-slate-400">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
frontend/tailwind.config.js
Normal file
18
frontend/tailwind.config.js
Normal file
@@ -0,0 +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: [],
|
||||||
|
}
|
||||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user