diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e210dea1..e2e5500d 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -24,20 +24,20 @@ reviews: - path: "**/*.{ts,tsx}" instructions: | Review TypeScript and React code for Termix server management platform. Key considerations: - + **Architecture & Patterns:** - Follow the established multi-port backend architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085) - Use proper separation between Desktop and Mobile UI components - Maintain consistent state management patterns with React hooks and context - Follow the established tab-based navigation system - + **Database & Backend:** - Use Drizzle ORM with SQLite for database operations - Implement proper JWT authentication middleware patterns - Follow the established API error handling patterns in main-axios.ts - Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.) - Maintain proper input validation and sanitization - + **UI/UX Guidelines:** - Use Shadcn/UI components with Tailwind CSS for consistent styling - Follow the established theme system with dark/light mode support @@ -48,13 +48,13 @@ reviews: - Follow the established color token system (--primary, --secondary, --background, etc.) - Use proper Tailwind CSS classes instead of inline styles - Implement proper focus states and accessibility indicators - + **SSH & Security:** - Implement proper SSH connection management with session handling - Use secure credential storage and management patterns - Follow the established authentication flow (password, key, credential-based) - Implement proper file operation security and validation - + **Code Quality:** - Use proper TypeScript types from the centralized types/index.ts - Follow the established API patterns in main-axios.ts @@ -65,7 +65,7 @@ reviews: - Use proper component interaction patterns through props and callbacks - Follow the established state management patterns with useState and useEffect - Use proper event handling and form submission patterns - + **Bug Detection & Fixes:** - Identify and fix memory leaks in useEffect cleanup functions - Fix missing dependency arrays in useEffect hooks @@ -82,27 +82,27 @@ reviews: - Identify and fix performance issues and unnecessary re-renders - Fix improper API error handling and user feedback - Resolve authentication state inconsistencies and token management issues - + **Internationalization:** - Use the i18next translation system with proper t() function calls - Support both English and Chinese locales - Use proper translation keys and fallbacks - + **Performance:** - Implement proper cleanup in useEffect hooks - Use proper memoization where appropriate - Follow the established polling and refresh patterns - Implement proper connection pooling and resource management - + **Specific to Termix:** - Maintain compatibility with Electron and web versions - Follow the established terminal integration patterns with xterm.js - Use proper file manager operations and SSH session management - Implement proper tunnel management and status tracking - Follow the established alert and notification system patterns - + Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience. - + **General Bug Detection & Fixes:** - Identify and fix common React bugs (missing keys, improper state updates, memory leaks) - Fix TypeScript errors and type safety issues @@ -123,33 +123,33 @@ reviews: - path: "**/backend/**/*.{ts,js}" instructions: | Review backend code for Termix server management platform. Key considerations: - + **Backend Architecture:** - Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085) - Use Express.js with proper middleware patterns - Implement proper CORS and security headers - Use proper request/response logging with structured logging - + **Database Operations:** - Use Drizzle ORM with proper schema definitions - Implement proper database migrations and schema updates - Use proper transaction handling for critical operations - Follow the established database connection patterns - + **Authentication & Security:** - Implement proper JWT token validation and refresh - Use bcryptjs for password hashing with proper salt rounds - Implement proper input validation and sanitization - Use proper CORS configuration for security - Implement proper rate limiting and security headers - + **SSH Operations:** - Use ssh2 library with proper connection management - Implement proper SSH key handling and validation - Use proper session management and cleanup - Implement proper error handling for SSH operations - Use proper file operation security and validation - + **API Design:** - Follow RESTful API patterns with proper HTTP status codes - Implement proper error response formatting @@ -159,25 +159,25 @@ reviews: - Use the established multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085) - Follow the established error handling patterns with handleApiError function - Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.) - + **Logging & Monitoring:** - Use the structured logging system with proper context - Implement proper error tracking and reporting - Use proper performance monitoring and metrics - Implement proper health checks and status endpoints - + Highlight any security vulnerabilities, performance issues, or architectural deviations. - path: "**/components/**/*.{ts,tsx}" instructions: | Review UI components for Termix server management platform. Key considerations: - + **Component Design:** - Use Shadcn/UI components as the foundation - Implement proper component composition and reusability - Use proper TypeScript interfaces and prop types - Follow the established component naming conventions - + **Styling & Theming:** - Use Tailwind CSS with proper responsive design - Implement proper dark/light theme support @@ -187,91 +187,91 @@ reviews: - Follow the established color scheme and design tokens - Use proper Tailwind CSS utility classes instead of custom CSS - Implement proper focus states and hover effects - + **State Management:** - Use proper React hooks and context patterns - Implement proper state lifting and prop drilling avoidance - Use proper memoization with useMemo and useCallback - Implement proper cleanup in useEffect hooks - + **Form Handling:** - Use react-hook-form with proper validation - Implement proper form state management - Use proper error handling and user feedback - Implement proper accessibility for form elements - + **SSH Integration:** - Implement proper SSH connection status indicators - Use proper terminal integration with xterm.js - Implement proper file manager operations - Use proper tunnel status and management UI - + Highlight any UI/UX issues, accessibility problems, or performance concerns. - path: "**/types/**/*.{ts,js}" instructions: | Review type definitions for Termix server management platform. Key considerations: - + **Type Design:** - Use proper TypeScript interfaces and type definitions - Implement proper type safety and validation - Use proper generic types and utility types - Follow the established type naming conventions - + **API Types:** - Define proper request/response types for all API endpoints - Use proper error types and status codes - Implement proper validation types and schemas - Use proper pagination and filtering types - + **SSH Types:** - Define proper SSH connection and configuration types - Use proper tunnel and credential types - Implement proper file operation types - Use proper authentication and security types - + **Type Safety:** - Ensure proper type coverage and completeness - Use proper strict type checking - Implement proper type narrowing and guards - Use proper type assertions and casting - + Highlight any type safety issues, missing types, or type inconsistencies. - path: "**/hooks/**/*.{ts,tsx}" instructions: | Review custom hooks for Termix server management platform. Key considerations: - + **Hook Design:** - Use proper React hooks patterns and conventions - Implement proper hook composition and reusability - Use proper TypeScript types for hook parameters and return values - Follow the established hook naming conventions - + **State Management:** - Implement proper state management with useState and useReducer - Use proper context and provider patterns - Implement proper state persistence and synchronization - Use proper state cleanup and memory management - + **Side Effects:** - Use proper useEffect patterns with proper dependencies - Implement proper cleanup functions and resource management - Use proper async operations and error handling - Implement proper polling and refresh patterns - + **Performance:** - Use proper memoization with useMemo and useCallback - Implement proper debouncing and throttling - Use proper lazy loading and code splitting - Implement proper optimization patterns - + **SSH Integration:** - Implement proper SSH connection management hooks - Use proper terminal integration hooks - Implement proper file manager operation hooks - Use proper tunnel management hooks - + **Hook-Specific Bug Detection:** - Fix missing cleanup functions in useEffect hooks that cause memory leaks - Resolve infinite loops caused by incorrect dependency arrays @@ -283,25 +283,25 @@ reviews: - Identify and fix custom hook dependency issues - Resolve improper memoization that causes stale data - Fix improper error handling in custom hooks - + Highlight any hook design issues, performance problems, or reusability concerns. - path: "**/lib/**/*.{ts,js}" instructions: | Review utility libraries and helper functions for Termix server management platform. Key considerations: - + **Utility Functions:** - Implement proper utility functions with clear purposes - Use proper TypeScript types and JSDoc documentation - Implement proper error handling and validation - Follow the established utility naming conventions - + **Logging System:** - Use proper structured logging with context and metadata - Implement proper log levels and filtering - Use proper log formatting and output - Implement proper log rotation and cleanup - + **API Utilities:** - Implement proper API client configuration and management - Use proper request/response interceptors @@ -311,19 +311,19 @@ reviews: - Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi) - Follow the established error handling patterns with handleApiError function - Use proper structured logging with service-specific loggers - + **Security Utilities:** - Implement proper input validation and sanitization - Use proper encryption and decryption functions - Implement proper secure random generation - Use proper security headers and CORS handling - + **SSH Utilities:** - Implement proper SSH connection utilities - Use proper SSH key handling and validation - Implement proper SSH command execution - Use proper SSH file operation utilities - + **Utility Bug Detection:** - Fix improper error handling in utility functions that could crash the application - Resolve null/undefined access errors in utility functions @@ -335,61 +335,61 @@ reviews: - Identify and fix performance bottlenecks in utility functions - Fix improper data transformation and serialization issues - Resolve improper configuration and environment variable handling - + Highlight any utility design issues, performance problems, or security concerns. - path: "**/main-axios.ts" instructions: | Review main-axios.ts API client configuration for Termix server management platform. Key considerations: - + **API Client Architecture:** - Maintain the multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085) - Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi) - Implement proper API instance creation with createApiInstance function - Use proper base URL configuration for different environments (dev, production, Electron) - + **Error Handling:** - Use the centralized handleApiError function for consistent error handling - Implement proper error classification (auth, network, validation, server errors) - Use proper error logging with service-specific loggers - Implement proper error response formatting and user-friendly messages - + **Request/Response Interceptors:** - Implement proper JWT token handling in request interceptors - Use proper request timing and performance logging - Implement proper response logging and error tracking - Use proper authentication token refresh and cleanup - + **API Function Organization:** - Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.) - Use proper TypeScript types for all API functions - Implement proper parameter validation and sanitization - Use proper return type definitions and error handling - + **Authentication:** - Implement proper JWT token management and refresh - Use proper cookie handling for web and Electron environments - Implement proper authentication state management - Use proper token expiration and cleanup - + **Logging:** - Use proper structured logging with context and metadata - Implement proper request/response logging with performance metrics - Use proper error logging with appropriate log levels - Implement proper service-specific logger selection - + **Performance:** - Implement proper request timeout and retry logic - Use proper connection pooling and resource management - Implement proper request deduplication and caching - Use proper performance monitoring and metrics - + **Security:** - Implement proper input validation and sanitization - Use proper CORS and security header handling - Implement proper authentication and authorization - Use proper secure communication and data handling - + **API Bug Detection:** - Fix improper error handling that could expose sensitive information - Resolve race conditions in concurrent API calls @@ -403,31 +403,31 @@ reviews: - Resolve improper authentication token refresh and cleanup - Fix improper CORS and security header configuration - Identify and fix potential security vulnerabilities in API handling - + Highlight any API design issues, error handling problems, or security concerns. - path: "**/electron/**/*.{ts,js,cjs}" instructions: | Review Electron application code for Termix server management platform. Key considerations: - + **Electron Architecture:** - Use proper Electron main and renderer process separation - Implement proper IPC (Inter-Process Communication) patterns - Use proper security and sandboxing configurations - Follow the established Electron best practices - + **Security:** - Implement proper security policies and configurations - Use proper context isolation and node integration - Implement proper CSP and security headers - Use proper authentication and authorization handling - + **Performance:** - Implement proper memory management and cleanup - Use proper resource optimization and caching - Implement proper background processing and threading - Use proper performance monitoring and profiling - + **Electron Bug Detection:** - Fix improper IPC communication that could cause crashes - Resolve memory leaks in Electron main and renderer processes @@ -441,31 +441,31 @@ reviews: - Resolve improper tray and menu functionality issues - Fix improper security policies and CSP configuration - Identify and fix potential security vulnerabilities in Electron setup - + Highlight any Electron-specific issues, security vulnerabilities, or performance problems. - path: "**/docker/**/*" instructions: | Review Docker configuration files for Termix server management platform. Key considerations: - + **Dockerfile Design:** - Use proper multi-stage builds for optimization - Implement proper layer caching and optimization - Use proper security and minimal base images - Follow the established Docker best practices - + **Security:** - Implement proper user and permission management - Use proper security scanning and vulnerability assessment - Implement proper secrets and credential management - Use proper network security and isolation - + **Performance:** - Implement proper resource optimization and allocation - Use proper caching and build optimization - Implement proper monitoring and logging - Use proper health checks and status monitoring - + **Docker Bug Detection:** - Fix improper multi-stage build optimization that causes large images - Resolve security vulnerabilities in base images and dependencies @@ -479,91 +479,91 @@ reviews: - Resolve improper backup and recovery procedures - Fix improper scaling and load balancing configuration - Identify and fix potential security vulnerabilities in Docker setup - + Highlight any Docker configuration issues, security vulnerabilities, or performance problems. - path: "**/*.md" instructions: | Review documentation files for Termix server management platform. Key considerations: - + **Documentation Quality:** - Ensure proper grammar, spelling, and clarity - Use proper formatting and structure - Implement proper code examples and snippets - Follow the established documentation standards - + **Content Accuracy:** - Ensure proper technical accuracy and completeness - Use proper up-to-date information and examples - Implement proper cross-references and links - Use proper version and compatibility information - + **User Experience:** - Ensure proper user-friendly language and explanations - Use proper step-by-step instructions and guides - Implement proper troubleshooting and FAQ sections - Use proper visual aids and diagrams where appropriate - + Highlight any documentation issues, inaccuracies, or missing information. - path: "**/index.css" instructions: | Review index.css styling configuration for Termix server management platform. Key considerations: - + **CSS Variable System:** - Define proper CSS custom properties for colors, spacing, and typography - Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.) - Implement proper dark/light theme variable definitions - Use proper semantic color naming (--destructive, --muted, --accent, etc.) - + **Design System:** - Follow the established design token system - Use proper color palette definitions with proper contrast ratios - Implement proper typography scale and font family definitions - Use proper spacing and sizing scale definitions - + **Theme Support:** - Implement proper dark and light theme variable definitions - Use proper CSS custom property fallbacks - Implement proper theme switching support - Use proper color scheme media queries - + **Component Styling:** - Define proper base styles for common components - Use proper utility classes and helper styles - Implement proper responsive design utilities - Use proper accessibility-focused styling - + **Color Management:** - Avoid hardcoded color values, use CSS variables instead - Implement proper color contrast and accessibility - Use proper semantic color definitions - Implement proper color state variations (hover, focus, active) - + **Typography:** - Define proper font family and weight definitions - Use proper line height and letter spacing - Implement proper text size and hierarchy - Use proper font loading and fallback strategies - + **Layout Utilities:** - Define proper spacing and margin utilities - Use proper flexbox and grid utilities - Implement proper responsive breakpoint utilities - Use proper container and layout helpers - + **Accessibility:** - Implement proper focus indicators and states - Use proper color contrast ratios - Implement proper reduced motion support - Use proper screen reader friendly styling - + **Performance:** - Use efficient CSS selectors and properties - Implement proper CSS organization and structure - Use proper CSS custom property optimization - Implement proper critical CSS and loading strategies - + Highlight any styling issues, accessibility problems, or design system inconsistencies. auto_review: enabled: true @@ -575,4 +575,4 @@ reviews: - "TEST" drafts: false chat: - auto_reply: true \ No newline at end of file + auto_reply: true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index bc5b825a..908aaba5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: [ LukeGus ] \ No newline at end of file +github: [LukeGus] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index aa45c083..55f187ee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,8 +3,7 @@ name: Bug report about: Create a report to help Termix improve title: "[BUG]" labels: bug -assignees: '' - +assignees: "" --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots or console/Docker logs to help explain your problem. **Environment (please complete the following information):** - - Browser [e.g. chrome, safari] - - Version [e.g. 1.6.0] + +- Browser [e.g. chrome, safari] +- Version [e.g. 1.6.0] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6051e2ef..8f421adb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,8 +3,7 @@ name: Feature request about: Suggest an idea for Termix title: "[FEATURE]" labels: enhancement -assignees: '' - +assignees: "" --- **Is your feature request related to a problem? Please describe.** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c5949a83..8bb2f443 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,4 +37,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b2bf6f80..0cb53035 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -5,8 +5,8 @@ on: branches: - development paths-ignore: - - '**.md' - - '.gitignore' + - "**.md" + - ".gitignore" workflow_dispatch: inputs: tag_name: diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 3211f6a1..293f23c9 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -5,9 +5,9 @@ on: branches: - development paths-ignore: - - '**.md' - - '.gitignore' - - 'docker/**' + - "**.md" + - ".gitignore" + - "docker/**" workflow_dispatch: inputs: build_type: @@ -34,8 +34,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci @@ -79,8 +79,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci @@ -100,4 +100,3 @@ jobs: name: Termix-Linux-Portable path: Termix-Linux-Portable.zip retention-days: 30 - diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..1b8ac889 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da7949ca..d3e7f12f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -_# Contributing +\_# Contributing ## Prerequisites @@ -9,13 +9,13 @@ _# Contributing ## Installation 1. Clone the repository: - ```sh - git clone https://github.com/LukeGus/Termix - ``` + ```sh + git clone https://github.com/LukeGus/Termix + ``` 2. Install the dependencies: - ```sh - npm install - ``` + ```sh + npm install + ``` ## Running the development server @@ -34,18 +34,18 @@ This will start the backend and the frontend Vite server. You can access Termix 1. **Fork the repository**: Click the "Fork" button at the top right of the [repository page](https://github.com/LukeGus/Termix). 2. **Create a new branch**: - ```sh - git checkout -b feature/my-new-feature - ``` + ```sh + git checkout -b feature/my-new-feature + ``` 3. **Make your changes**: Implement your feature, fix, or improvement. 4. **Commit your changes**: - ```sh - git commit -m "Feature request my new feature" - ``` + ```sh + git commit -m "Feature request my new feature" + ``` 5. **Push to your fork**: - ```sh - git push origin feature/my-feature-request - ``` + ```sh + git push origin feature/my-feature-request + ``` 6. **Open a pull request**: Go to the original repository and create a PR with a clear description. ## 📝 Guidelines @@ -62,7 +62,7 @@ This will start the backend and the frontend Vite server. You can access Termix ### Background Colors | CSS Variable | Color Value | Usage | Description | -|-------------------------------|-------------|-----------------------------|------------------------------------------| +| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- | | `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | @@ -74,7 +74,7 @@ This will start the backend and the frontend Vite server. You can access Termix ### Element-Specific Backgrounds | CSS Variable | Color Value | Usage | Description | -|--------------------------|-------------|--------------------|-----------------------------------------------| +| ------------------------ | ----------- | ------------------ | --------------------------------------------- | | `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements | | `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | @@ -83,7 +83,7 @@ This will start the backend and the frontend Vite server. You can access Termix ### Border Colors | CSS Variable | Color Value | Usage | Description | -|------------------------------|-------------|-----------------|------------------------------------------| +| ---------------------------- | ----------- | --------------- | ---------------------------------------- | | `--color-dark-border` | `#303032` | Default borders | Standard border color | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | @@ -94,7 +94,7 @@ This will start the backend and the frontend Vite server. You can access Termix ### Interactive States | CSS Variable | Color Value | Usage | Description | -|--------------------------|-------------|-------------------|-----------------------------------------------| +| ------------------------ | ----------- | ----------------- | --------------------------------------------- | | `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects | | `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements | | `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements | @@ -104,4 +104,4 @@ This will start the backend and the frontend Vite server. You can access Termix If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) -repo. \ No newline at end of file +repo. diff --git a/README-CN.md b/README-CN.md index 39400868..5cee2e1f 100644 --- a/README-CN.md +++ b/README-CN.md @@ -1,109 +1,109 @@ -# 仓库统计 - -

- English 英文 | - 中文 中文 -

- -![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) -Discord - -#### 核心技术 - -[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) -[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#) -[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) -[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#) -[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#) -[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#) -[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#) -[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#) - -
-

- - Termix Banner -

- -如果你愿意,可以在这里支持这个项目!\ -[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) - -# 概览 - -

- - Termix Banner -

- -Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix -提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。 - -# 功能 - -- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统 -- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控 -- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等) -- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹 -- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况 -- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP) -- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面 -- **语言支持** - 内置中英文支持 - -# 计划功能 - -- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能 -- **主题定制** - 修改所有工具的主题风格 -- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我) -- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器 - -# 安装 - -访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件: - -```yaml -services: - termix: - image: ghcr.io/lukegus/termix:latest - container_name: termix - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - termix-data:/app/data - environment: - PORT: "8080" - -volumes: - termix-data: - driver: local -``` - -# 支持 - -如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf) -服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。 - -# 展示 - -

- Termix Demo 1 - Termix Demo 2 -

- -

- Termix Demo 3 - Termix Demo 4 - Termix Demo 5 -

- -

- -

- -# 许可证 - -根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。 \ No newline at end of file +# 仓库统计 + +

+ English 英文 | + 中文 中文 +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) +Discord + +#### 核心技术 + +[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) +[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#) +[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) +[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#) +[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#) +[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#) +[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#) +[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#) + +
+

+ + Termix Banner +

+ +如果你愿意,可以在这里支持这个项目!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# 概览 + +

+ + Termix Banner +

+ +Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix +提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。 + +# 功能 + +- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统 +- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控 +- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等) +- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹 +- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况 +- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP) +- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面 +- **语言支持** - 内置中英文支持 + +# 计划功能 + +- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能 +- **主题定制** - 修改所有工具的主题风格 +- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我) +- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器 + +# 安装 + +访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# 支持 + +如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf) +服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。 + +# 展示 + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 + Termix Demo 5 +

+ +

+ +

+ +# 许可证 + +根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。 diff --git a/README.md b/README.md index dcbd7a8c..82688dd3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ 中文 中文

- ![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) @@ -81,7 +80,7 @@ services: volumes: termix-data: - driver: local + driver: local ``` Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app ( diff --git a/components.json b/components.json index 2082f482..8bfc737f 100644 --- a/components.json +++ b/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a4c55fad..5e7ec7e9 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,4 +12,4 @@ services: volumes: termix-data: - driver: local \ No newline at end of file + driver: local diff --git a/electron-builder.json b/electron-builder.json index afacd22b..21bdb711 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -15,9 +15,7 @@ "!vite.config.ts", "!eslint.config.js" ], - "asarUnpack": [ - "node_modules/node-fetch/**/*" - ], + "asarUnpack": ["node_modules/node-fetch/**/*"], "extraMetadata": { "main": "electron/main.cjs" }, @@ -43,4 +41,4 @@ "icon": "public/icon.png", "category": "Development" } -} \ No newline at end of file +} diff --git a/electron/main.cjs b/electron/main.cjs index 692a1e81..7c42cdf5 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,297 +1,334 @@ -const {app, BrowserWindow, shell, ipcMain} = require('electron'); -const path = require('path'); -const fs = require('fs'); +const { app, BrowserWindow, shell, ipcMain } = require("electron"); +const path = require("path"); +const fs = require("fs"); let mainWindow = null; -const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; +const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { - console.log('Another instance is already running, quitting...'); - app.quit(); - process.exit(0); + console.log("Another instance is already running, quitting..."); + app.quit(); + process.exit(0); } else { - app.on('second-instance', (event, commandLine, workingDirectory) => { - console.log('Second instance detected, focusing existing window...'); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - mainWindow.show(); - } - }); + app.on("second-instance", (event, commandLine, workingDirectory) => { + console.log("Second instance detected, focusing existing window..."); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + mainWindow.show(); + } + }); } function createWindow() { - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - title: 'Termix', - icon: isDev - ? path.join(__dirname, '..', 'public', 'icon.png') - : path.join(process.resourcesPath, 'public', 'icon.png'), - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - webSecurity: !isDev, - preload: path.join(__dirname, 'preload.js') - }, - show: false - }); + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + title: "Termix", + icon: isDev + ? path.join(__dirname, "..", "public", "icon.png") + : path.join(process.resourcesPath, "public", "icon.png"), + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: !isDev, + preload: path.join(__dirname, "preload.js"), + }, + show: false, + }); - if (process.platform !== 'darwin') { - mainWindow.setMenuBarVisibility(false); + if (process.platform !== "darwin") { + mainWindow.setMenuBarVisibility(false); + } + + if (isDev) { + mainWindow.loadURL("http://localhost:5173"); + mainWindow.webContents.openDevTools(); + } else { + const indexPath = path.join(__dirname, "..", "dist", "index.html"); + console.log("Loading frontend from:", indexPath); + mainWindow.loadFile(indexPath); + } + + mainWindow.once("ready-to-show", () => { + console.log("Window ready to show"); + mainWindow.show(); + }); + + mainWindow.webContents.on( + "did-fail-load", + (event, errorCode, errorDescription, validatedURL) => { + console.error( + "Failed to load:", + errorCode, + errorDescription, + validatedURL, + ); + }, + ); + + mainWindow.webContents.on("did-finish-load", () => { + console.log("Frontend loaded successfully"); + }); + + mainWindow.on("close", (event) => { + if (process.platform === "darwin") { + event.preventDefault(); + mainWindow.hide(); } + }); - if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools(); - } else { - const indexPath = path.join(__dirname, '..', 'dist', 'index.html'); - console.log('Loading frontend from:', indexPath); - mainWindow.loadFile(indexPath); - } + mainWindow.on("closed", () => { + mainWindow = null; + }); - mainWindow.once('ready-to-show', () => { - console.log('Window ready to show'); - mainWindow.show(); - }); - - mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { - console.error('Failed to load:', errorCode, errorDescription, validatedURL); - }); - - mainWindow.webContents.on('did-finish-load', () => { - console.log('Frontend loaded successfully'); - }); - - mainWindow.on('close', (event) => { - if (process.platform === 'darwin') { - event.preventDefault(); - mainWindow.hide(); - } - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); - - mainWindow.webContents.setWindowOpenHandler(({url}) => { - shell.openExternal(url); - return {action: 'deny'}; - }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); } -ipcMain.handle('get-app-version', () => { - return app.getVersion(); +ipcMain.handle("get-app-version", () => { + return app.getVersion(); }); -ipcMain.handle('get-platform', () => { - return process.platform; +ipcMain.handle("get-platform", () => { + return process.platform; }); -ipcMain.handle('get-server-config', () => { - try { - const userDataPath = app.getPath('userData'); - const configPath = path.join(userDataPath, 'server-config.json'); +ipcMain.handle("get-server-config", () => { + try { + const userDataPath = app.getPath("userData"); + const configPath = path.join(userDataPath, "server-config.json"); - if (fs.existsSync(configPath)) { - const configData = fs.readFileSync(configPath, 'utf8'); - return JSON.parse(configData); - } - return null; - } catch (error) { - console.error('Error reading server config:', error); - return null; + if (fs.existsSync(configPath)) { + const configData = fs.readFileSync(configPath, "utf8"); + return JSON.parse(configData); } + return null; + } catch (error) { + console.error("Error reading server config:", error); + return null; + } }); -ipcMain.handle('save-server-config', (event, config) => { - try { - const userDataPath = app.getPath('userData'); - const configPath = path.join(userDataPath, 'server-config.json'); +ipcMain.handle("save-server-config", (event, config) => { + try { + const userDataPath = app.getPath("userData"); + const configPath = path.join(userDataPath, "server-config.json"); - if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, {recursive: true}); - } - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - return {success: true}; - } catch (error) { - console.error('Error saving server config:', error); - return {success: false, error: error.message}; + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); } + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return { success: true }; + } catch (error) { + console.error("Error saving server config:", error); + return { success: false, error: error.message }; + } }); - -ipcMain.handle('test-server-connection', async (event, serverUrl) => { +ipcMain.handle("test-server-connection", async (event, serverUrl) => { + try { + let fetch; try { - let fetch; - try { - fetch = globalThis.fetch || require('node:fetch'); - } catch (e) { - const https = require('https'); - const http = require('http'); - const {URL} = require('url'); + fetch = globalThis.fetch || require("node:fetch"); + } catch (e) { + const https = require("https"); + const http = require("http"); + const { URL } = require("url"); - fetch = (url, options = {}) => { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === 'https:'; - const client = isHttps ? https : http; + fetch = (url, options = {}) => { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === "https:"; + const client = isHttps ? https : http; - const req = client.request(url, { - method: options.method || 'GET', - headers: options.headers || {}, - timeout: options.timeout || 5000 - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - text: () => Promise.resolve(data), - json: () => Promise.resolve(JSON.parse(data)) - }); - }); - }); - - req.on('error', reject); - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timeout')); - }); - - if (options.body) { - req.write(options.body); - } - req.end(); + const req = client.request( + url, + { + method: options.method || "GET", + headers: options.headers || {}, + timeout: options.timeout || 5000, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + text: () => Promise.resolve(data), + json: () => Promise.resolve(JSON.parse(data)), }); - }; - } + }); + }, + ); - const normalizedServerUrl = serverUrl.replace(/\/$/, ''); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Request timeout")); + }); - const healthUrl = `${normalizedServerUrl}/health`; - - try { - const response = await fetch(healthUrl, { - method: 'GET', - timeout: 5000 - }); - - if (response.ok) { - const data = await response.text(); - - if (data.includes('') || data.includes('')) { - console.log('Health endpoint returned HTML instead of JSON - not a Termix server'); - return { - success: false, - error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' - }; - } - - try { - const healthData = JSON.parse(data); - if (healthData && ( - healthData.status === 'ok' || - healthData.status === 'healthy' || - healthData.healthy === true || - healthData.database === 'connected' - )) { - return {success: true, status: response.status, testedUrl: healthUrl}; - } - } catch (parseError) { - console.log('Health endpoint did not return valid JSON'); - } - } - } catch (urlError) { - console.error('Health check failed:', urlError); - } - - try { - const versionUrl = `${normalizedServerUrl}/version`; - const response = await fetch(versionUrl, { - method: 'GET', - timeout: 5000 - }); - - if (response.ok) { - const data = await response.text(); - - if (data.includes('') || data.includes('')) { - console.log('Version endpoint returned HTML instead of JSON - not a Termix server'); - return { - success: false, - error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' - }; - } - - try { - const versionData = JSON.parse(data); - if (versionData && ( - versionData.status === 'up_to_date' || - versionData.status === 'requires_update' || - (versionData.localVersion && versionData.version && versionData.latest_release) - )) { - return { - success: true, - status: response.status, - testedUrl: versionUrl, - warning: 'Health endpoint not available, but server appears to be running' - }; - } - } catch (parseError) { - console.log('Version endpoint did not return valid JSON'); - } - } - } catch (versionError) { - console.error('Version check failed:', versionError); - } - - return { - success: false, - error: 'Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.' - }; - } catch (error) { - return {success: false, error: error.message}; + if (options.body) { + req.write(options.body); + } + req.end(); + }); + }; } + + const normalizedServerUrl = serverUrl.replace(/\/$/, ""); + + const healthUrl = `${normalizedServerUrl}/health`; + + try { + const response = await fetch(healthUrl, { + method: "GET", + timeout: 5000, + }); + + if (response.ok) { + const data = await response.text(); + + if ( + data.includes("") || + data.includes("") + ) { + console.log( + "Health endpoint returned HTML instead of JSON - not a Termix server", + ); + return { + success: false, + error: + "Server returned HTML instead of JSON. This does not appear to be a Termix server.", + }; + } + + try { + const healthData = JSON.parse(data); + if ( + healthData && + (healthData.status === "ok" || + healthData.status === "healthy" || + healthData.healthy === true || + healthData.database === "connected") + ) { + return { + success: true, + status: response.status, + testedUrl: healthUrl, + }; + } + } catch (parseError) { + console.log("Health endpoint did not return valid JSON"); + } + } + } catch (urlError) { + console.error("Health check failed:", urlError); + } + + try { + const versionUrl = `${normalizedServerUrl}/version`; + const response = await fetch(versionUrl, { + method: "GET", + timeout: 5000, + }); + + if (response.ok) { + const data = await response.text(); + + if ( + data.includes("") || + data.includes("") + ) { + console.log( + "Version endpoint returned HTML instead of JSON - not a Termix server", + ); + return { + success: false, + error: + "Server returned HTML instead of JSON. This does not appear to be a Termix server.", + }; + } + + try { + const versionData = JSON.parse(data); + if ( + versionData && + (versionData.status === "up_to_date" || + versionData.status === "requires_update" || + (versionData.localVersion && + versionData.version && + versionData.latest_release)) + ) { + return { + success: true, + status: response.status, + testedUrl: versionUrl, + warning: + "Health endpoint not available, but server appears to be running", + }; + } + } catch (parseError) { + console.log("Version endpoint did not return valid JSON"); + } + } + } catch (versionError) { + console.error("Version check failed:", versionError); + } + + return { + success: false, + error: + "Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.", + }; + } catch (error) { + return { success: false, error: error.message }; + } }); app.whenReady().then(() => { + createWindow(); + console.log("Termix started successfully"); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { createWindow(); - console.log('Termix started successfully'); + } else if (mainWindow) { + mainWindow.show(); + } }); -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } +app.on("before-quit", () => { + console.log("App is quitting..."); }); -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } else if (mainWindow) { - mainWindow.show(); - } +app.on("will-quit", () => { + console.log("App will quit..."); }); -app.on('before-quit', () => { - console.log('App is quitting...'); +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); }); -app.on('will-quit', () => { - console.log('App will quit...'); -}); - -process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error); -}); - -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); }); diff --git a/electron/preload.js b/electron/preload.js index 9c222d73..e1e436d8 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,27 +1,29 @@ -const {contextBridge, ipcRenderer} = require('electron'); +const { contextBridge, ipcRenderer } = require("electron"); -contextBridge.exposeInMainWorld('electronAPI', { - getAppVersion: () => ipcRenderer.invoke('get-app-version'), - getPlatform: () => ipcRenderer.invoke('get-platform'), +contextBridge.exposeInMainWorld("electronAPI", { + getAppVersion: () => ipcRenderer.invoke("get-app-version"), + getPlatform: () => ipcRenderer.invoke("get-platform"), - getServerConfig: () => ipcRenderer.invoke('get-server-config'), - saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), - testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), + getServerConfig: () => ipcRenderer.invoke("get-server-config"), + saveServerConfig: (config) => + ipcRenderer.invoke("save-server-config", config), + testServerConnection: (serverUrl) => + ipcRenderer.invoke("test-server-connection", serverUrl), - showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), - showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), + showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options), + showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options), - onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), - onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), + onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback), + onUpdateDownloaded: (callback) => + ipcRenderer.on("update-downloaded", callback), - removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), - isElectron: true, - isDev: process.env.NODE_ENV === 'development', - - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), + isElectron: true, + isDev: process.env.NODE_ENV === "development", + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); window.IS_ELECTRON = true; -console.log('electronAPI exposed to window'); +console.log("electronAPI exposed to window"); diff --git a/eslint.config.js b/eslint.config.js index d94e7deb..f4616740 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,18 +1,18 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { globalIgnores } from "eslint/config"; export default tseslint.config([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], + reactHooks.configs["recommended-latest"], reactRefresh.configs.vite, ], languageOptions: { @@ -20,4 +20,4 @@ export default tseslint.config([ globals: globals.browser, }, }, -]) +]); diff --git a/openapi.json b/openapi.json index b8d6ce05..8c8c0a50 100644 --- a/openapi.json +++ b/openapi.json @@ -1,2214 +1,2257 @@ { - "openapi": "3.0.3", - "info": { - "title": "Termix API", - "version": "1.0.0", - "description": "Comprehensive API for Termix SSH management, file operations, tunneling, and server monitoring. This API provides endpoints for managing SSH hosts, file operations, tunnel connections, server monitoring, user management, and system alerts.", - "contact": { - "name": "Termix Development Team" + "openapi": "3.0.3", + "info": { + "title": "Termix API", + "version": "1.0.0", + "description": "Comprehensive API for Termix SSH management, file operations, tunneling, and server monitoring. This API provides endpoints for managing SSH hosts, file operations, tunnel connections, server monitoring, user management, and system alerts.", + "contact": { + "name": "Termix Development Team" + } + }, + "servers": [ + { + "url": "http://localhost:8081", + "description": "Main database and authentication server" + }, + { + "url": "http://localhost:8083", + "description": "SSH tunnel management server" + }, + { + "url": "http://localhost:8084", + "description": "SSH file manager server" + }, + { + "url": "http://localhost:8085", + "description": "Server statistics and monitoring server" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "System", + "description": "System health, version, and release information endpoints" + }, + { + "name": "SSH Hosts", + "description": "SSH host management, creation, updates, and deletion" + }, + { + "name": "File Manager", + "description": "File manager operations including recent, pinned, and shortcuts" + }, + { + "name": "SSH File Operations", + "description": "SSH file operations like reading, writing, creating, and deleting files" + }, + { + "name": "Tunnel Management", + "description": "SSH tunnel connection, disconnection, and status management" + }, + { + "name": "Server Statistics", + "description": "Server status monitoring and metrics collection" + }, + { + "name": "User Management", + "description": "User account management and administration" + }, + { + "name": "Authentication", + "description": "User authentication, login, and password management" + }, + { + "name": "TOTP", + "description": "Two-factor authentication using TOTP (Time-based One-Time Password)" + }, + { + "name": "Alerts", + "description": "System alerts and notifications management" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" } }, - "servers": [ - { - "url": "http://localhost:8081", - "description": "Main database and authentication server" + "schemas": { + "SSHHost": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "folder": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "pin": { "type": "boolean" }, + "authType": { "type": "string", "enum": ["password", "key"] }, + "password": { "type": "string" }, + "key": { "type": "string" }, + "keyPassword": { "type": "string" }, + "keyType": { "type": "string" }, + "enableTerminal": { "type": "boolean" }, + "enableTunnel": { "type": "boolean" }, + "enableFileManager": { "type": "boolean" }, + "defaultPath": { "type": "string" }, + "tunnelConnections": { + "type": "array", + "items": { "type": "object" } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": ["id", "ip", "port", "username", "authType"] }, - { - "url": "http://localhost:8083", - "description": "SSH tunnel management server" + "SSHHostData": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "folder": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "pin": { "type": "boolean" }, + "authType": { "type": "string", "enum": ["password", "key"] }, + "password": { "type": "string" }, + "key": { "type": "string" }, + "keyPassword": { "type": "string" }, + "keyType": { "type": "string" }, + "enableTerminal": { "type": "boolean" }, + "enableTunnel": { "type": "boolean" }, + "enableFileManager": { "type": "boolean" }, + "defaultPath": { "type": "string" }, + "tunnelConnections": { + "type": "array", + "items": { "type": "object" } + } + }, + "required": ["ip", "port", "username", "authType"] }, - { - "url": "http://localhost:8084", - "description": "SSH file manager server" + "TunnelConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "hostName": { "type": "string" }, + "sourceIP": { "type": "string" }, + "sourceSSHPort": { "type": "integer" }, + "sourceUsername": { "type": "string" }, + "sourcePassword": { "type": "string" }, + "sourceAuthMethod": { "type": "string" }, + "sourceSSHKey": { "type": "string" }, + "sourceKeyPassword": { "type": "string" }, + "sourceKeyType": { "type": "string" }, + "endpointIP": { "type": "string" }, + "endpointSSHPort": { "type": "integer" }, + "endpointUsername": { "type": "string" }, + "endpointPassword": { "type": "string" }, + "endpointAuthMethod": { "type": "string" }, + "endpointSSHKey": { "type": "string" }, + "endpointKeyPassword": { "type": "string" }, + "endpointKeyType": { "type": "string" }, + "sourcePort": { "type": "integer" }, + "endpointPort": { "type": "integer" }, + "maxRetries": { "type": "integer" }, + "retryInterval": { "type": "integer" }, + "autoStart": { "type": "boolean" }, + "isPinned": { "type": "boolean" } + }, + "required": [ + "name", + "hostName", + "sourceIP", + "sourceSSHPort", + "sourceUsername", + "endpointIP", + "endpointSSHPort", + "endpointUsername", + "sourcePort", + "endpointPort" + ] }, - { - "url": "http://localhost:8085", - "description": "Server statistics and monitoring server" - } - ], - "security": [ - { - "bearerAuth": [] - } - ], - "tags": [ - { - "name": "System", - "description": "System health, version, and release information endpoints" - }, - { - "name": "SSH Hosts", - "description": "SSH host management, creation, updates, and deletion" - }, - { - "name": "File Manager", - "description": "File manager operations including recent, pinned, and shortcuts" - }, - { - "name": "SSH File Operations", - "description": "SSH file operations like reading, writing, creating, and deleting files" - }, - { - "name": "Tunnel Management", - "description": "SSH tunnel connection, disconnection, and status management" - }, - { - "name": "Server Statistics", - "description": "Server status monitoring and metrics collection" - }, - { - "name": "User Management", - "description": "User account management and administration" - }, - { - "name": "Authentication", - "description": "User authentication, login, and password management" - }, - { - "name": "TOTP", - "description": "Two-factor authentication using TOTP (Time-based One-Time Password)" - }, - { - "name": "Alerts", - "description": "System alerts and notifications management" - } - ], - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" + "TunnelStatus": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "reason": { "type": "string" }, + "errorType": { "type": "string" }, + "retryCount": { "type": "integer" }, + "maxRetries": { "type": "integer" }, + "nextRetryIn": { "type": "integer" }, + "retryExhausted": { "type": "boolean" } } }, - "schemas": { - "SSHHost": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "ip": { "type": "string" }, - "port": { "type": "integer" }, - "username": { "type": "string" }, - "folder": { "type": "string" }, - "tags": { "type": "array", "items": { "type": "string" } }, - "pin": { "type": "boolean" }, - "authType": { "type": "string", "enum": ["password", "key"] }, - "password": { "type": "string" }, - "key": { "type": "string" }, - "keyPassword": { "type": "string" }, - "keyType": { "type": "string" }, - "enableTerminal": { "type": "boolean" }, - "enableTunnel": { "type": "boolean" }, - "enableFileManager": { "type": "boolean" }, - "defaultPath": { "type": "string" }, - "tunnelConnections": { "type": "array", "items": { "type": "object" } }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - }, - "required": ["id", "ip", "port", "username", "authType"] - }, - "SSHHostData": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "ip": { "type": "string" }, - "port": { "type": "integer" }, - "username": { "type": "string" }, - "folder": { "type": "string" }, - "tags": { "type": "array", "items": { "type": "string" } }, - "pin": { "type": "boolean" }, - "authType": { "type": "string", "enum": ["password", "key"] }, - "password": { "type": "string" }, - "key": { "type": "string" }, - "keyPassword": { "type": "string" }, - "keyType": { "type": "string" }, - "enableTerminal": { "type": "boolean" }, - "enableTunnel": { "type": "boolean" }, - "enableFileManager": { "type": "boolean" }, - "defaultPath": { "type": "string" }, - "tunnelConnections": { "type": "array", "items": { "type": "object" } } - }, - "required": ["ip", "port", "username", "authType"] - }, - "TunnelConfig": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "hostName": { "type": "string" }, - "sourceIP": { "type": "string" }, - "sourceSSHPort": { "type": "integer" }, - "sourceUsername": { "type": "string" }, - "sourcePassword": { "type": "string" }, - "sourceAuthMethod": { "type": "string" }, - "sourceSSHKey": { "type": "string" }, - "sourceKeyPassword": { "type": "string" }, - "sourceKeyType": { "type": "string" }, - "endpointIP": { "type": "string" }, - "endpointSSHPort": { "type": "integer" }, - "endpointUsername": { "type": "string" }, - "endpointPassword": { "type": "string" }, - "endpointAuthMethod": { "type": "string" }, - "endpointSSHKey": { "type": "string" }, - "endpointKeyPassword": { "type": "string" }, - "endpointKeyType": { "type": "string" }, - "sourcePort": { "type": "integer" }, - "endpointPort": { "type": "integer" }, - "maxRetries": { "type": "integer" }, - "retryInterval": { "type": "integer" }, - "autoStart": { "type": "boolean" }, - "isPinned": { "type": "boolean" } - }, - "required": ["name", "hostName", "sourceIP", "sourceSSHPort", "sourceUsername", "endpointIP", "endpointSSHPort", "endpointUsername", "sourcePort", "endpointPort"] - }, - "TunnelStatus": { - "type": "object", - "properties": { - "status": { "type": "string" }, - "reason": { "type": "string" }, - "errorType": { "type": "string" }, - "retryCount": { "type": "integer" }, - "maxRetries": { "type": "integer" }, - "nextRetryIn": { "type": "integer" }, - "retryExhausted": { "type": "boolean" } - } - }, - "ServerStatus": { - "type": "object", - "properties": { - "status": { "type": "string", "enum": ["online", "offline"] }, - "lastChecked": { "type": "string", "format": "date-time" } - } - }, - "ServerMetrics": { - "type": "object", - "properties": { - "cpu": { - "type": "object", - "properties": { - "percent": { "type": "number" }, - "cores": { "type": "number" }, - "load": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 } - } - }, - "memory": { - "type": "object", - "properties": { - "percent": { "type": "number" }, - "usedGiB": { "type": "number" }, - "totalGiB": { "type": "number" } - } - }, - "disk": { - "type": "object", - "properties": { - "percent": { "type": "number" }, - "usedHuman": { "type": "string" }, - "totalHuman": { "type": "string" } - } - }, - "lastChecked": { "type": "string", "format": "date-time" } - } - }, - "FileManagerFile": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "path": { "type": "string" }, - "type": { "type": "string", "enum": ["file", "directory"] }, - "isSSH": { "type": "boolean" }, - "sshSessionId": { "type": "string" } - }, - "required": ["name", "path"] - }, - "UserInfo": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "username": { "type": "string" }, - "is_admin": { "type": "boolean" } - }, - "required": ["id", "username", "is_admin"] - }, - "AuthResponse": { - "type": "object", - "properties": { - "token": { "type": "string" } - }, - "required": ["token"] - }, - "Error": { - "type": "object", - "properties": { - "error": { "type": "string" }, - "details": { "type": "string" } - } + "ServerStatus": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["online", "offline"] }, + "lastChecked": { "type": "string", "format": "date-time" } } }, - "parameters": { - "hostId": { - "name": "hostId", - "in": "query", - "description": "The ID of the SSH host", - "required": true, - "schema": { - "type": "integer" - } - }, - "sessionId": { - "name": "sessionId", - "in": "query", - "description": "The SSH session identifier", - "required": true, - "schema": { - "type": "string" - } - }, - "path": { - "name": "path", - "in": "query", - "description": "The file or directory path", - "required": true, - "schema": { - "type": "string" - } - }, - "tunnelName": { - "name": "tunnelName", - "in": "path", - "description": "The name of the tunnel", - "required": true, - "schema": { - "type": "string" - } - }, - "userId": { - "name": "userId", - "in": "path", - "description": "The user identifier", - "required": true, - "schema": { - "type": "string" - } - }, - "hostIdPath": { - "name": "id", - "in": "path", - "description": "The SSH host identifier", - "required": true, - "schema": { - "type": "integer" - } - }, - "serverIdPath": { - "name": "id", - "in": "path", - "description": "The server identifier", - "required": true, - "schema": { - "type": "integer" - } + "ServerMetrics": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "cores": { "type": "number" }, + "load": { + "type": "array", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + } + } + }, + "memory": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "usedGiB": { "type": "number" }, + "totalGiB": { "type": "number" } + } + }, + "disk": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "usedHuman": { "type": "string" }, + "totalHuman": { "type": "string" } + } + }, + "lastChecked": { "type": "string", "format": "date-time" } } }, - "responses": { - "BadRequest": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } + "FileManagerFile": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "type": { "type": "string", "enum": ["file", "directory"] }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" } }, - "Unauthorized": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } + "required": ["name", "path"] + }, + "UserInfo": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "username": { "type": "string" }, + "is_admin": { "type": "boolean" } }, - "NotFound": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } + "required": ["id", "username", "is_admin"] + }, + "AuthResponse": { + "type": "object", + "properties": { + "token": { "type": "string" } }, - "InternalServerError": { - "description": "Internal Server Error", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } + "required": ["token"] + }, + "Error": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "details": { "type": "string" } } } }, - "paths": { - "/health": { - "get": { - "summary": "Health check endpoint", - "description": "Simple health check to verify the API server is running and responsive. **Server: localhost:8081**", - "operationId": "getHealth", - "tags": ["System"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { "type": "string", "example": "ok" } - } + "parameters": { + "hostId": { + "name": "hostId", + "in": "query", + "description": "The ID of the SSH host", + "required": true, + "schema": { + "type": "integer" + } + }, + "sessionId": { + "name": "sessionId", + "in": "query", + "description": "The SSH session identifier", + "required": true, + "schema": { + "type": "string" + } + }, + "path": { + "name": "path", + "in": "query", + "description": "The file or directory path", + "required": true, + "schema": { + "type": "string" + } + }, + "tunnelName": { + "name": "tunnelName", + "in": "path", + "description": "The name of the tunnel", + "required": true, + "schema": { + "type": "string" + } + }, + "userId": { + "name": "userId", + "in": "path", + "description": "The user identifier", + "required": true, + "schema": { + "type": "string" + } + }, + "hostIdPath": { + "name": "id", + "in": "path", + "description": "The SSH host identifier", + "required": true, + "schema": { + "type": "integer" + } + }, + "serverIdPath": { + "name": "id", + "in": "path", + "description": "The server identifier", + "required": true, + "schema": { + "type": "integer" + } + } + }, + "responses": { + "BadRequest": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "Unauthorized": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "NotFound": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "InternalServerError": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "paths": { + "/health": { + "get": { + "summary": "Health check endpoint", + "description": "Simple health check to verify the API server is running and responsive. **Server: localhost:8081**", + "operationId": "getHealth", + "tags": ["System"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "string", "example": "ok" } } } } } } } - }, - "/version": { - "get": { - "summary": "Get version information and check for updates", - "description": "Get version information and check for updates. **Server: localhost:8081**", - "operationId": "getVersion", - "tags": ["System"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { "type": "string", "enum": ["up_to_date", "requires_update"] }, - "version": { "type": "string" }, - "latest_release": { - "type": "object", - "properties": { - "tag_name": { "type": "string" }, - "name": { "type": "string" }, - "published_at": { "type": "string" }, - "html_url": { "type": "string" } - } - }, - "cached": { "type": "boolean" }, - "cache_age": { "type": "number" } - } + } + }, + "/version": { + "get": { + "summary": "Get version information and check for updates", + "description": "Get version information and check for updates. **Server: localhost:8081**", + "operationId": "getVersion", + "tags": ["System"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["up_to_date", "requires_update"] + }, + "version": { "type": "string" }, + "latest_release": { + "type": "object", + "properties": { + "tag_name": { "type": "string" }, + "name": { "type": "string" }, + "published_at": { "type": "string" }, + "html_url": { "type": "string" } + } + }, + "cached": { "type": "boolean" }, + "cache_age": { "type": "number" } } } } - }, - "401": { - "description": "Version information not available", - "content": { - "text/plain": { - "schema": { "type": "string" } - } + } + }, + "401": { + "description": "Version information not available", + "content": { + "text/plain": { + "schema": { "type": "string" } } } } } - }, - "/releases/rss": { - "get": { - "summary": "Get releases in RSS format", - "description": "Get releases in RSS format. **Server: localhost:8081**", - "operationId": "getReleasesRSS", - "tags": ["System"], - "parameters": [ - { - "name": "page", - "in": "query", - "schema": { "type": "integer", "default": 1 } - }, - { - "name": "per_page", - "in": "query", - "schema": { "type": "integer", "default": 20, "maximum": 100 } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "feed": { + } + }, + "/releases/rss": { + "get": { + "summary": "Get releases in RSS format", + "description": "Get releases in RSS format. **Server: localhost:8081**", + "operationId": "getReleasesRSS", + "tags": ["System"], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { "type": "integer", "default": 1 } + }, + { + "name": "per_page", + "in": "query", + "schema": { "type": "integer", "default": 20, "maximum": 100 } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feed": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "link": { "type": "string" }, + "updated": { "type": "string" } + } + }, + "items": { + "type": "array", + "items": { "type": "object", "properties": { + "id": { "type": "integer" }, "title": { "type": "string" }, "description": { "type": "string" }, "link": { "type": "string" }, - "updated": { "type": "string" } - } - }, - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "link": { "type": "string" }, - "pubDate": { "type": "string" }, - "version": { "type": "string" }, - "isPrerelease": { "type": "boolean" }, - "isDraft": { "type": "boolean" }, - "assets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "size": { "type": "number" }, - "download_count": { "type": "number" }, - "download_url": { "type": "string" } - } + "pubDate": { "type": "string" }, + "version": { "type": "string" }, + "isPrerelease": { "type": "boolean" }, + "isDraft": { "type": "boolean" }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "size": { "type": "number" }, + "download_count": { "type": "number" }, + "download_url": { "type": "string" } } } } } - }, - "total_count": { "type": "integer" }, - "cached": { "type": "boolean" }, - "cache_age": { "type": "number" } - } + } + }, + "total_count": { "type": "integer" }, + "cached": { "type": "boolean" }, + "cache_age": { "type": "number" } } } } } } } - }, - "/ssh/db/host": { - "get": { - "summary": "Get all SSH hosts", - "description": "Retrieve a list of all configured SSH hosts in the system. This endpoint requires authentication and returns host information including connection details, authentication methods, and enabled features. **Server: localhost:8081**", - "operationId": "getSSHHosts", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "responses": { - "200": { - "description": "Successfully retrieved SSH hosts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { "$ref": "#/components/schemas/SSHHost" } - } - } - } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - }, - "post": { - "summary": "Create a new SSH host", - "description": "Create a new SSH host configuration. **Server: localhost:8081**", - "operationId": "createSSHHost", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, + } + }, + "/ssh/db/host": { + "get": { + "summary": "Get all SSH hosts", + "description": "Retrieve a list of all configured SSH hosts in the system. This endpoint requires authentication and returns host information including connection details, authentication methods, and enabled features. **Server: localhost:8081**", + "operationId": "getSSHHosts", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "Successfully retrieved SSH hosts", "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "format": "binary", - "description": "SSH private key file (optional)" - }, - "data": { - "type": "string", - "description": "JSON string containing host data" - } - } - } - }, "application/json": { - "schema": { "$ref": "#/components/schemas/SSHHostData" } + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/SSHHost" } + } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SSHHost" } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/db/host/{id}": { - "get": { - "summary": "Get SSH host by ID", - "description": "Get SSH host by ID. **Server: localhost:8081**", - "operationId": "getSSHHostById", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostIdPath" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SSHHost" } + "post": { + "summary": "Create a new SSH host", + "description": "Create a new SSH host configuration. **Server: localhost:8081**", + "operationId": "createSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "binary", + "description": "SSH private key file (optional)" + }, + "data": { + "type": "string", + "description": "JSON string containing host data" + } } } }, - "404": { "$ref": "#/components/responses/NotFound" }, - "401": { "$ref": "#/components/responses/Unauthorized" } + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHostData" } + } } }, - "put": { - "summary": "Update SSH host", - "description": "Update SSH host configuration. **Server: localhost:8081**", - "operationId": "updateSSHHost", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostIdPath" - } - ], - "requestBody": { - "required": true, + "responses": { + "200": { + "description": "OK", "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "format": "binary", - "description": "SSH private key file (optional)" - }, - "data": { - "type": "string", - "description": "JSON string containing host data" - } - } - } - }, "application/json": { - "schema": { "$ref": "#/components/schemas/SSHHostData" } + "schema": { "$ref": "#/components/schemas/SSHHost" } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SSHHost" } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/db/host/{id}": { + "get": { + "summary": "Get SSH host by ID", + "description": "Get SSH host by ID. **Server: localhost:8081**", + "operationId": "getSSHHostById", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHost" } + } + } + }, + "404": { "$ref": "#/components/responses/NotFound" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "put": { + "summary": "Update SSH host", + "description": "Update SSH host configuration. **Server: localhost:8081**", + "operationId": "updateSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "binary", + "description": "SSH private key file (optional)" + }, + "data": { + "type": "string", + "description": "JSON string containing host data" + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" } + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHostData" } + } } }, - "delete": { - "summary": "Delete SSH host", - "description": "Delete SSH host configuration. **Server: localhost:8081**", - "operationId": "deleteSSHHost", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostIdPath" + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHost" } + } } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } } }, - "/ssh/db/folders": { - "get": { - "summary": "Get all SSH host folders", - "description": "Get all SSH host folders. **Server: localhost:8081**", - "operationId": "getSSHFolders", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { "type": "string" } - } - } - } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + "delete": { + "summary": "Delete SSH host", + "description": "Delete SSH host configuration. **Server: localhost:8081**", + "operationId": "deleteSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" } - } - }, - "/ssh/bulk-import": { - "post": { - "summary": "Bulk import SSH hosts", - "description": "Bulk import SSH hosts. **Server: localhost:8081**", - "operationId": "bulkImportSSHHosts", - "tags": ["SSH Hosts"], - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "hosts": { + "message": { "type": "string" } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/ssh/db/folders": { + "get": { + "summary": "Get all SSH host folders", + "description": "Get all SSH host folders. **Server: localhost:8081**", + "operationId": "getSSHFolders", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/bulk-import": { + "post": { + "summary": "Bulk import SSH hosts", + "description": "Bulk import SSH hosts. **Server: localhost:8081**", + "operationId": "bulkImportSSHHosts", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hosts": { + "type": "array", + "items": { "$ref": "#/components/schemas/SSHHostData" } + } + }, + "required": ["hosts"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "success": { "type": "integer" }, + "failed": { "type": "integer" }, + "errors": { "type": "array", - "items": { "$ref": "#/components/schemas/SSHHostData" } + "items": { "type": "string" } } - }, - "required": ["hosts"] + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "success": { "type": "integer" }, - "failed": { "type": "integer" }, - "errors": { - "type": "array", - "items": { "type": "string" } - } - } - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/recent": { + "get": { + "summary": "Get recent files for a host", + "description": "Get recent files for a host. **Server: localhost:8081**", + "operationId": "getFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/FileManagerFile" } } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/file_manager/recent": { - "get": { - "summary": "Get recent files for a host", - "description": "Get recent files for a host. **Server: localhost:8081**", - "operationId": "getFileManagerRecent", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostId" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { "$ref": "#/components/schemas/FileManagerFile" } - } - } + "post": { + "summary": "Add file to recent list", + "description": "Add file to recent list. **Server: localhost:8081**", + "operationId": "addFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + } } }, - "post": { - "summary": "Add file to recent list", - "description": "Add file to recent list. **Server: localhost:8081**", - "operationId": "addFileManagerRecent", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { "type": "string" }, - "path": { "type": "string" }, - "isSSH": { "type": "boolean" }, - "sshSessionId": { "type": "string" }, - "hostId": { "type": "integer" } - }, - "required": ["name", "path", "isSSH", "hostId"] + "message": { "type": "string" } + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - }, - "delete": { - "summary": "Remove file from recent list", - "description": "Remove file from recent list. **Server: localhost:8081**", - "operationId": "removeFileManagerRecent", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "name": "name", - "in": "query", - "description": "File name", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "path", - "in": "query", - "description": "File path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "isSSH", - "in": "query", - "description": "Whether this is an SSH file", - "required": true, - "schema": { "type": "boolean" } - }, - { - "name": "sshSessionId", - "in": "query", - "description": "SSH session ID", - "required": false, - "schema": { "type": "string" } - }, - { - "name": "hostId", - "in": "query", - "description": "Host ID", - "required": true, - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/file_manager/pinned": { - "get": { - "summary": "Get pinned files for a host", - "description": "Get pinned files for a host. **Server: localhost:8081**", - "operationId": "getFileManagerPinned", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostId" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { "$ref": "#/components/schemas/FileManagerFile" } - } - } - } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - }, - "post": { - "summary": "Add file to pinned list", - "description": "Add file to pinned list. **Server: localhost:8081**", - "operationId": "addFileManagerPinned", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "requestBody": { + "delete": { + "summary": "Remove file from recent list", + "description": "Remove file from recent list. **Server: localhost:8081**", + "operationId": "removeFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { "type": "string" }, - "path": { "type": "string" }, - "isSSH": { "type": "boolean" }, - "sshSessionId": { "type": "string" }, - "hostId": { "type": "integer" } - }, - "required": ["name", "path", "isSSH", "hostId"] + "message": { "type": "string" } + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/pinned": { + "get": { + "summary": "Get pinned files for a host", + "description": "Get pinned files for a host. **Server: localhost:8081**", + "operationId": "getFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/FileManagerFile" } } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - }, - "delete": { - "summary": "Remove file from pinned list", - "description": "Remove file from pinned list. **Server: localhost:8081**", - "operationId": "removeFileManagerPinned", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "name": "name", - "in": "query", - "description": "File name", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "path", - "in": "query", - "description": "File path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "isSSH", - "in": "query", - "description": "Whether this is an SSH file", - "required": true, - "schema": { "type": "boolean" } - }, - { - "name": "sshSessionId", - "in": "query", - "description": "SSH session ID", - "required": false, - "schema": { "type": "string" } - }, - { - "name": "hostId", - "in": "query", - "description": "Host ID", - "required": true, - "schema": { "type": "integer" } } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/file_manager/shortcuts": { - "get": { - "summary": "Get file shortcuts for a host", - "description": "Get file shortcuts for a host. **Server: localhost:8081**", - "operationId": "getFileManagerShortcuts", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "$ref": "#/components/parameters/hostId" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "path": { "type": "string" } - }, - "required": ["name", "path"] - } - } - } + "post": { + "summary": "Add file to pinned list", + "description": "Add file to pinned list. **Server: localhost:8081**", + "operationId": "addFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + } } }, - "post": { - "summary": "Add file shortcut", - "description": "Add file shortcut. **Server: localhost:8081**", - "operationId": "addFileManagerShortcut", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { "type": "string" }, - "path": { "type": "string" }, - "isSSH": { "type": "boolean" }, - "sshSessionId": { "type": "string" }, - "hostId": { "type": "integer" } - }, - "required": ["name", "path", "isSSH", "hostId"] + "message": { "type": "string" } + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - }, - "delete": { - "summary": "Remove file shortcut", - "description": "Remove file shortcut. **Server: localhost:8081**", - "operationId": "removeFileManagerShortcut", - "tags": ["File Manager"], - "security": [{ "bearerAuth": [] }], - "parameters": [ - { - "name": "name", - "in": "query", - "description": "File name", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "path", - "in": "query", - "description": "File path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "isSSH", - "in": "query", - "description": "Whether this is an SSH file", - "required": true, - "schema": { "type": "boolean" } - }, - { - "name": "sshSessionId", - "in": "query", - "description": "SSH session ID", - "required": false, - "schema": { "type": "string" } - }, - { - "name": "hostId", - "in": "query", - "description": "Host ID", - "required": true, - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/file_manager/ssh/connect": { - "post": { - "summary": "Connect to SSH server", - "description": "Connect to SSH server. **Server: localhost:8084**", - "operationId": "connectSSH", - "tags": ["SSH File Operations"], - "requestBody": { + "delete": { + "summary": "Remove file from pinned list", + "description": "Remove file from pinned list. **Server: localhost:8081**", + "operationId": "removeFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "sessionId": { "type": "string" }, - "ip": { "type": "string" }, - "port": { "type": "integer" }, - "username": { "type": "string" }, - "password": { "type": "string" }, - "sshKey": { "type": "string" }, - "keyPassword": { "type": "string" } - }, - "required": ["sessionId", "ip", "username", "port"] + "message": { "type": "string" } + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" } - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } } - }, - "/ssh/file_manager/ssh/disconnect": { - "post": { - "summary": "Disconnect from SSH server", - "description": "Disconnect from SSH server. **Server: localhost:8084**", - "operationId": "disconnectSSH", - "tags": ["SSH File Operations"], - "requestBody": { - "required": true, + } + }, + "/ssh/file_manager/shortcuts": { + "get": { + "summary": "Get file shortcuts for a host", + "description": "Get file shortcuts for a host. **Server: localhost:8081**", + "operationId": "getFileManagerShortcuts", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "sessionId": { "type": "string" } - }, - "required": ["sessionId"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { + "type": "array", + "items": { "type": "object", "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/status": { - "get": { - "summary": "Get SSH connection status", - "description": "Get SSH connection status. **Server: localhost:8084**", - "operationId": "getSSHStatus", - "tags": ["SSH File Operations"], - "parameters": [ - { - "$ref": "#/components/parameters/sessionId" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connected": { "type": "boolean" } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/listFiles": { - "get": { - "summary": "List files in SSH directory", - "description": "List files in SSH directory. **Server: localhost:8084**", - "operationId": "listSSHFiles", - "tags": ["SSH File Operations"], - "parameters": [ - { - "$ref": "#/components/parameters/sessionId" - }, - { - "$ref": "#/components/parameters/path" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "path": { "type": "string" }, - "type": { "type": "string", "enum": ["file", "directory"] }, - "size": { "type": "number" }, - "modified": { "type": "string" }, - "permissions": { "type": "string" } - } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/readFile": { - "get": { - "summary": "Read SSH file content", - "description": "Read SSH file content. **Server: localhost:8084**", - "operationId": "readSSHFile", - "tags": ["SSH File Operations"], - "parameters": [ - { - "$ref": "#/components/parameters/sessionId" - }, - { - "$ref": "#/components/parameters/path" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content": { "type": "string" }, + "name": { "type": "string" }, "path": { "type": "string" } - } + }, + "required": ["name", "path"] } } } } - } - } - }, - "/ssh/file_manager/ssh/writeFile": { - "post": { - "summary": "Write content to SSH file", - "description": "Write content to SSH file. **Server: localhost:8084**", - "operationId": "writeSSHFile", - "tags": ["SSH File Operations"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionId": { "type": "string" }, - "path": { "type": "string" }, - "content": { "type": "string" } - }, - "required": ["sessionId", "path", "content"] - } - } - } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } + "401": { "$ref": "#/components/responses/Unauthorized" } } }, - "/ssh/file_manager/ssh/createFile": { - "post": { - "summary": "Create new SSH file", - "description": "Create new SSH file. **Server: localhost:8084**", - "operationId": "createSSHFile", - "tags": ["SSH File Operations"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionId": { "type": "string" }, - "path": { "type": "string" }, - "fileName": { "type": "string" }, - "content": { "type": "string" } - }, - "required": ["sessionId", "path", "fileName"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/createFolder": { - "post": { - "summary": "Create new SSH folder", - "description": "Create new SSH folder. **Server: localhost:8084**", - "operationId": "createSSHFolder", - "tags": ["SSH File Operations"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionId": { "type": "string" }, - "path": { "type": "string" }, - "folderName": { "type": "string" } - }, - "required": ["sessionId", "path", "folderName"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/deleteItem": { - "delete": { - "summary": "Delete SSH file or folder", - "description": "Delete SSH file or folder. **Server: localhost:8084**", - "operationId": "deleteSSHItem", - "tags": ["SSH File Operations"], - "parameters": [ - { - "name": "sessionId", - "in": "query", - "description": "SSH session ID", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "path", - "in": "query", - "description": "File or directory path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "isDirectory", - "in": "query", - "description": "Whether the item is a directory", - "required": true, - "schema": { "type": "boolean" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/file_manager/ssh/renameItem": { - "put": { - "summary": "Rename SSH file or folder", - "description": "Rename SSH file or folder. **Server: localhost:8084**", - "operationId": "renameSSHItem", - "tags": ["SSH File Operations"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionId": { "type": "string" }, - "oldPath": { "type": "string" }, - "newName": { "type": "string" } - }, - "required": ["sessionId", "oldPath", "newName"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/tunnel/status": { - "get": { - "summary": "Get all tunnel statuses", - "description": "Get all tunnel statuses. **Server: localhost:8083**", - "operationId": "getTunnelStatuses", - "tags": ["Tunnel Management"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { "$ref": "#/components/schemas/TunnelStatus" } - } - } - } - } - } - } - }, - "/ssh/tunnel/status/{tunnelName}": { - "get": { - "summary": "Get tunnel status by name", - "description": "Get tunnel status by name. **Server: localhost:8083**", - "operationId": "getTunnelStatusByName", - "tags": ["Tunnel Management"], - "parameters": [ - { - "$ref": "#/components/parameters/tunnelName" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/TunnelStatus" } - } - } - } - } - } - }, - "/ssh/tunnel/connect": { - "post": { - "summary": "Connect to tunnel", - "description": "Connect to tunnel. **Server: localhost:8083**", - "operationId": "connectTunnel", - "tags": ["Tunnel Management"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/TunnelConfig" } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/tunnel/disconnect": { - "post": { - "summary": "Disconnect tunnel", - "description": "Disconnect tunnel. **Server: localhost:8083**", - "operationId": "disconnectTunnel", - "tags": ["Tunnel Management"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tunnelName": { "type": "string" } - }, - "required": ["tunnelName"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/ssh/tunnel/cancel": { - "post": { - "summary": "Cancel tunnel connection", - "description": "Cancel tunnel connection. **Server: localhost:8083**", - "operationId": "cancelTunnel", - "tags": ["Tunnel Management"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tunnelName": { "type": "string" } - }, - "required": ["tunnelName"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/status": { - "get": { - "summary": "Get all server statuses", - "description": "Get all server statuses. **Server: localhost:8085**", - "operationId": "getAllServerStatuses", - "tags": ["Server Statistics"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { "$ref": "#/components/schemas/ServerStatus" } - } - } - } - } - } - } - }, - "/status/{id}": { - "get": { - "summary": "Get server status by ID", - "description": "Get server status by ID. **Server: localhost:8085**", - "operationId": "getServerStatusById", - "tags": ["Server Statistics"], - "parameters": [ - { - "$ref": "#/components/parameters/serverIdPath" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ServerStatus" } - } - } - } - } - } - }, - "/metrics/{id}": { - "get": { - "summary": "Get server metrics by ID", - "description": "Get server metrics by ID. **Server: localhost:8085**", - "operationId": "getServerMetricsById", - "tags": ["Server Statistics"], - "parameters": [ - { - "$ref": "#/components/parameters/serverIdPath" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ServerMetrics" } - } - } - } - } - } - }, - "/refresh": { - "post": { - "summary": "Refresh server statistics", - "description": "Refresh server statistics. **Server: localhost:8085**", - "operationId": "refreshServerStats", - "tags": ["Server Statistics"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - } - } - } - }, - "/users/create": { - "post": { - "summary": "Create new user account", - "description": "Create new user account. **Server: localhost:8081**", - "operationId": "createUser", - "tags": ["User Management"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { "type": "string" }, - "password": { "type": "string" } - }, - "required": ["username", "password"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" } - } - } - }, - "/users/login": { - "post": { - "summary": "User login", - "description": "User login. **Server: localhost:8081**", - "operationId": "loginUser", - "tags": ["Authentication"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { "type": "string" }, - "password": { "type": "string" } - }, - "required": ["username", "password"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AuthResponse" } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - } - }, - "/users/me": { - "get": { - "summary": "Get current user info", - "description": "Get current user info. **Server: localhost:8081**", - "operationId": "getUserInfo", - "tags": ["User Management"], - "security": [{ "bearerAuth": [] }], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UserInfo" } - } - } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } - } - } - }, - "/users/count": { - "get": { - "summary": "Get total user count", - "description": "Get total user count. **Server: localhost:8081**", - "operationId": "getUserCount", - "tags": ["User Management"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "count": { "type": "integer" } - } - } - } - } - } - } - } - }, - "/users/registration-allowed": { - "get": { - "summary": "Check if user registration is allowed", - "description": "Check if user registration is allowed. **Server: localhost:8081**", - "operationId": "getRegistrationAllowed", - "tags": ["User Management"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "allowed": { "type": "boolean" } - } - } - } + "post": { + "summary": "Add file shortcut", + "description": "Add file shortcut. **Server: localhost:8081**", + "operationId": "addFileManagerShortcut", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] } } } }, - "patch": { - "summary": "Update registration allowed status", - "description": "Update registration allowed status. **Server: localhost:8081**", - "operationId": "updateRegistrationAllowed", - "tags": ["User Management"], - "security": [{ "bearerAuth": [] }], - "requestBody": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "delete": { + "summary": "Remove file shortcut", + "description": "Remove file shortcut. **Server: localhost:8081**", + "operationId": "removeFileManagerShortcut", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/ssh/connect": { + "post": { + "summary": "Connect to SSH server", + "description": "Connect to SSH server. **Server: localhost:8084**", + "operationId": "connectSSH", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "sshKey": { "type": "string" }, + "keyPassword": { "type": "string" } + }, + "required": ["sessionId", "ip", "username", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/ssh/file_manager/ssh/disconnect": { + "post": { + "summary": "Disconnect from SSH server", + "description": "Disconnect from SSH server. **Server: localhost:8084**", + "operationId": "disconnectSSH", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" } + }, + "required": ["sessionId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/status": { + "get": { + "summary": "Get SSH connection status", + "description": "Get SSH connection status. **Server: localhost:8084**", + "operationId": "getSSHStatus", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connected": { "type": "boolean" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/listFiles": { + "get": { + "summary": "List files in SSH directory", + "description": "List files in SSH directory. **Server: localhost:8084**", + "operationId": "listSSHFiles", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + }, + { + "$ref": "#/components/parameters/path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "type": { + "type": "string", + "enum": ["file", "directory"] + }, + "size": { "type": "number" }, + "modified": { "type": "string" }, + "permissions": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/readFile": { + "get": { + "summary": "Read SSH file content", + "description": "Read SSH file content. **Server: localhost:8084**", + "operationId": "readSSHFile", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + }, + { + "$ref": "#/components/parameters/path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "path": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/writeFile": { + "post": { + "summary": "Write content to SSH file", + "description": "Write content to SSH file. **Server: localhost:8084**", + "operationId": "writeSSHFile", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["sessionId", "path", "content"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/createFile": { + "post": { + "summary": "Create new SSH file", + "description": "Create new SSH file. **Server: localhost:8084**", + "operationId": "createSSHFile", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "fileName": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["sessionId", "path", "fileName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/createFolder": { + "post": { + "summary": "Create new SSH folder", + "description": "Create new SSH folder. **Server: localhost:8084**", + "operationId": "createSSHFolder", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "folderName": { "type": "string" } + }, + "required": ["sessionId", "path", "folderName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/deleteItem": { + "delete": { + "summary": "Delete SSH file or folder", + "description": "Delete SSH file or folder. **Server: localhost:8084**", + "operationId": "deleteSSHItem", + "tags": ["SSH File Operations"], + "parameters": [ + { + "name": "sessionId", + "in": "query", + "description": "SSH session ID", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File or directory path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isDirectory", + "in": "query", + "description": "Whether the item is a directory", + "required": true, + "schema": { "type": "boolean" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/renameItem": { + "put": { + "summary": "Rename SSH file or folder", + "description": "Rename SSH file or folder. **Server: localhost:8084**", + "operationId": "renameSSHItem", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "oldPath": { "type": "string" }, + "newName": { "type": "string" } + }, + "required": ["sessionId", "oldPath", "newName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/status": { + "get": { + "summary": "Get all tunnel statuses", + "description": "Get all tunnel statuses. **Server: localhost:8083**", + "operationId": "getTunnelStatuses", + "tags": ["Tunnel Management"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/TunnelStatus" + } + } + } + } + } + } + } + }, + "/ssh/tunnel/status/{tunnelName}": { + "get": { + "summary": "Get tunnel status by name", + "description": "Get tunnel status by name. **Server: localhost:8083**", + "operationId": "getTunnelStatusByName", + "tags": ["Tunnel Management"], + "parameters": [ + { + "$ref": "#/components/parameters/tunnelName" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TunnelStatus" } + } + } + } + } + } + }, + "/ssh/tunnel/connect": { + "post": { + "summary": "Connect to tunnel", + "description": "Connect to tunnel. **Server: localhost:8083**", + "operationId": "connectTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TunnelConfig" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/disconnect": { + "post": { + "summary": "Disconnect tunnel", + "description": "Disconnect tunnel. **Server: localhost:8083**", + "operationId": "disconnectTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tunnelName": { "type": "string" } + }, + "required": ["tunnelName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/cancel": { + "post": { + "summary": "Cancel tunnel connection", + "description": "Cancel tunnel connection. **Server: localhost:8083**", + "operationId": "cancelTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tunnelName": { "type": "string" } + }, + "required": ["tunnelName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/status": { + "get": { + "summary": "Get all server statuses", + "description": "Get all server statuses. **Server: localhost:8085**", + "operationId": "getAllServerStatuses", + "tags": ["Server Statistics"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ServerStatus" + } + } + } + } + } + } + } + }, + "/status/{id}": { + "get": { + "summary": "Get server status by ID", + "description": "Get server status by ID. **Server: localhost:8085**", + "operationId": "getServerStatusById", + "tags": ["Server Statistics"], + "parameters": [ + { + "$ref": "#/components/parameters/serverIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServerStatus" } + } + } + } + } + } + }, + "/metrics/{id}": { + "get": { + "summary": "Get server metrics by ID", + "description": "Get server metrics by ID. **Server: localhost:8085**", + "operationId": "getServerMetricsById", + "tags": ["Server Statistics"], + "parameters": [ + { + "$ref": "#/components/parameters/serverIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServerMetrics" } + } + } + } + } + } + }, + "/refresh": { + "post": { + "summary": "Refresh server statistics", + "description": "Refresh server statistics. **Server: localhost:8085**", + "operationId": "refreshServerStats", + "tags": ["Server Statistics"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/create": { + "post": { + "summary": "Create new user account", + "description": "Create new user account. **Server: localhost:8081**", + "operationId": "createUser", + "tags": ["User Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["username", "password"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/users/login": { + "post": { + "summary": "User login", + "description": "User login. **Server: localhost:8081**", + "operationId": "loginUser", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["username", "password"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/me": { + "get": { + "summary": "Get current user info", + "description": "Get current user info. **Server: localhost:8081**", + "operationId": "getUserInfo", + "tags": ["User Management"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UserInfo" } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/count": { + "get": { + "summary": "Get total user count", + "description": "Get total user count. **Server: localhost:8081**", + "operationId": "getUserCount", + "tags": ["User Management"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { "type": "integer" } + } + } + } + } + } + } + } + }, + "/users/registration-allowed": { + "get": { + "summary": "Check if user registration is allowed", + "description": "Check if user registration is allowed. **Server: localhost:8081**", + "operationId": "getRegistrationAllowed", + "tags": ["User Management"], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "allowed": { "type": "boolean" } - }, - "required": ["allowed"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } } } } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + } } } }, - "/users/initiate-reset": { - "post": { - "summary": "Initiate password reset", - "description": "Initiate password reset. **Server: localhost:8081**", - "operationId": "initiatePasswordReset", - "tags": ["Authentication"], - "requestBody": { - "required": true, + "patch": { + "summary": "Update registration allowed status", + "description": "Update registration allowed status. **Server: localhost:8081**", + "operationId": "updateRegistrationAllowed", + "tags": ["User Management"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowed": { "type": "boolean" } + }, + "required": ["allowed"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "username": { "type": "string" } - }, - "required": ["username"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } + "message": { "type": "string" } } } } } - } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } } - }, - "/users/verify-reset-code": { - "post": { - "summary": "Verify password reset code", - "description": "Verify password reset code. **Server: localhost:8081**", - "operationId": "verifyPasswordResetCode", - "tags": ["Authentication"], - "requestBody": { - "required": true, + } + }, + "/users/initiate-reset": { + "post": { + "summary": "Initiate password reset", + "description": "Initiate password reset. **Server: localhost:8081**", + "operationId": "initiatePasswordReset", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "username": { "type": "string" }, - "resetCode": { "type": "string" } - }, - "required": ["username", "resetCode"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tempToken": { "type": "string" } - } + "message": { "type": "string" } } } } } } } - }, - "/users/complete-reset": { - "post": { - "summary": "Complete password reset", - "description": "Complete password reset. **Server: localhost:8081**", - "operationId": "completePasswordReset", - "tags": ["Authentication"], - "requestBody": { - "required": true, + } + }, + "/users/verify-reset-code": { + "post": { + "summary": "Verify password reset code", + "description": "Verify password reset code. **Server: localhost:8081**", + "operationId": "verifyPasswordResetCode", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "resetCode": { "type": "string" } + }, + "required": ["username", "resetCode"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "username": { "type": "string" }, - "tempToken": { "type": "string" }, - "newPassword": { "type": "string" } - }, - "required": ["username", "tempToken", "newPassword"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } + "tempToken": { "type": "string" } } } } } } } - }, - "/users/totp/setup": { - "post": { - "summary": "Setup TOTP authentication", - "description": "Setup TOTP authentication. **Server: localhost:8081**", - "operationId": "setupTOTP", - "tags": ["TOTP"], - "security": [{ "bearerAuth": [] }], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "secret": { "type": "string" }, - "qr_code": { "type": "string" } - } - } - } + } + }, + "/users/complete-reset": { + "post": { + "summary": "Complete password reset", + "description": "Complete password reset. **Server: localhost:8081**", + "operationId": "completePasswordReset", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "tempToken": { "type": "string" }, + "newPassword": { "type": "string" } + }, + "required": ["username", "tempToken", "newPassword"] } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + } } - } - }, - "/users/totp/enable": { - "post": { - "summary": "Enable TOTP authentication", - "description": "Enable TOTP authentication. **Server: localhost:8081**", - "operationId": "enableTOTP", - "tags": ["TOTP"], - "security": [{ "bearerAuth": [] }], - "requestBody": { - "required": true, + }, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "totp_code": { "type": "string" } - }, - "required": ["totp_code"] + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/totp/setup": { + "post": { + "summary": "Setup TOTP authentication", + "description": "Setup TOTP authentication. **Server: localhost:8081**", + "operationId": "setupTOTP", + "tags": ["TOTP"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { "type": "string" }, + "qr_code": { "type": "string" } + } } } } }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/totp/enable": { + "post": { + "summary": "Enable TOTP authentication", + "description": "Enable TOTP authentication. **Server: localhost:8081**", + "operationId": "enableTOTP", + "tags": ["TOTP"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totp_code": { "type": "string" } + }, + "required": ["totp_code"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "backup_codes": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/totp/verify-login": { + "post": { + "summary": "Verify TOTP during login", + "description": "Verify TOTP during login. **Server: localhost:8081**", + "operationId": "verifyTOTPLogin", + "tags": ["TOTP"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "temp_token": { "type": "string" }, + "totp_code": { "type": "string" } + }, + "required": ["temp_token", "totp_code"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" } + } + } + } + } + } + }, + "/alerts": { + "get": { + "summary": "Get all system alerts", + "description": "Get all system alerts. **Server: localhost:8081**", + "operationId": "getAllAlerts", + "tags": ["Alerts"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "object", "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, "message": { "type": "string" }, - "backup_codes": { - "type": "array", - "items": { "type": "string" } - } + "expiresAt": { "type": "string" }, + "priority": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + "type": { + "type": "string", + "enum": ["info", "warning", "error", "success"] + }, + "actionUrl": { "type": "string" }, + "actionText": { "type": "string" } } } } } - }, - "401": { "$ref": "#/components/responses/Unauthorized" } + } } } - }, - "/users/totp/verify-login": { - "post": { - "summary": "Verify TOTP during login", - "description": "Verify TOTP during login. **Server: localhost:8081**", - "operationId": "verifyTOTPLogin", - "tags": ["TOTP"], - "requestBody": { - "required": true, + } + }, + "/alerts/user/{userId}": { + "get": { + "summary": "Get alerts for specific user", + "description": "Get alerts for specific user. **Server: localhost:8081**", + "operationId": "getUserAlerts", + "tags": ["Alerts"], + "parameters": [ + { + "$ref": "#/components/parameters/userId" + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "temp_token": { "type": "string" }, - "totp_code": { "type": "string" } - }, - "required": ["temp_token", "totp_code"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AuthResponse" } - } - } - } - } - } - }, - "/alerts": { - "get": { - "summary": "Get all system alerts", - "description": "Get all system alerts. **Server: localhost:8081**", - "operationId": "getAllAlerts", - "tags": ["Alerts"], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "message": { "type": "string" }, - "expiresAt": { "type": "string" }, - "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, - "type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, - "actionUrl": { "type": "string" }, - "actionText": { "type": "string" } - } - } - } - } - } - } - } - } - }, - "/alerts/user/{userId}": { - "get": { - "summary": "Get alerts for specific user", - "description": "Get alerts for specific user. **Server: localhost:8081**", - "operationId": "getUserAlerts", - "tags": ["Alerts"], - "parameters": [ - { - "$ref": "#/components/parameters/userId" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "alerts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "message": { "type": "string" }, - "expiresAt": { "type": "string" }, - "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, - "type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, - "actionUrl": { "type": "string" }, - "actionText": { "type": "string" } - } + "alerts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "message": { "type": "string" }, + "expiresAt": { "type": "string" }, + "priority": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + "type": { + "type": "string", + "enum": ["info", "warning", "error", "success"] + }, + "actionUrl": { "type": "string" }, + "actionText": { "type": "string" } } } } @@ -2218,38 +2261,38 @@ } } } - }, - "/alerts/dismiss": { - "post": { - "summary": "Dismiss an alert", - "description": "Dismiss an alert. **Server: localhost:8081**", - "operationId": "dismissAlert", - "tags": ["Alerts"], - "requestBody": { - "required": true, + } + }, + "/alerts/dismiss": { + "post": { + "summary": "Dismiss an alert", + "description": "Dismiss an alert. **Server: localhost:8081**", + "operationId": "dismissAlert", + "tags": ["Alerts"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { "type": "string" }, + "alertId": { "type": "string" } + }, + "required": ["userId", "alertId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { - "userId": { "type": "string" }, - "alertId": { "type": "string" } - }, - "required": ["userId", "alertId"] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" } - } + "message": { "type": "string" } } } } @@ -2259,4 +2302,4 @@ } } } - \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index bc3bd030..d34f84e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "prettier": "3.6.2", "ts-node": "^10.9.2", "tw-animate-css": "^1.3.5", "typescript": "~5.9.2", @@ -13330,6 +13331,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", diff --git a/package.json b/package.json index 744424b8..78bd621d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "main": "electron/main.cjs", "type": "module", "scripts": { + "clean": "npx prettier . --write", "dev": "vite", "build": "vite build && tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json", @@ -114,6 +115,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "prettier": "3.6.2", "ts-node": "^10.9.2", "tw-animate-css": "^1.3.5", "typescript": "~5.9.2", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 91c4597f..d3aa6e41 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1,252 +1,290 @@ -import express from 'express'; -import bodyParser from 'body-parser'; -import userRoutes from './routes/users.js'; -import sshRoutes from './routes/ssh.js'; -import alertRoutes from './routes/alerts.js'; -import credentialsRoutes from './routes/credentials.js'; -import cors from 'cors'; -import fetch from 'node-fetch'; -import fs from 'fs'; -import path from 'path'; -import 'dotenv/config'; -import {databaseLogger, apiLogger} from '../utils/logger.js'; +import express from "express"; +import bodyParser from "body-parser"; +import userRoutes from "./routes/users.js"; +import sshRoutes from "./routes/ssh.js"; +import alertRoutes from "./routes/alerts.js"; +import credentialsRoutes from "./routes/credentials.js"; +import cors from "cors"; +import fetch from "node-fetch"; +import fs from "fs"; +import path from "path"; +import "dotenv/config"; +import { databaseLogger, apiLogger } from "../utils/logger.js"; const app = express(); -app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + }), +); interface CacheEntry { - data: any; - timestamp: number; - expiresAt: number; + data: any; + timestamp: number; + expiresAt: number; } class GitHubCache { - private cache: Map = new Map(); - private readonly CACHE_DURATION = 30 * 60 * 1000; + private cache: Map = new Map(); + private readonly CACHE_DURATION = 30 * 60 * 1000; - set(key: string, data: any): void { - const now = Date.now(); - this.cache.set(key, { - data, - timestamp: now, - expiresAt: now + this.CACHE_DURATION - }); + set(key: string, data: any): void { + const now = Date.now(); + this.cache.set(key, { + data, + timestamp: now, + expiresAt: now + this.CACHE_DURATION, + }); + } + + get(key: string): any | null { + const entry = this.cache.get(key); + if (!entry) { + return null; } - get(key: string): any | null { - const entry = this.cache.get(key); - if (!entry) { - return null; - } - - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return null; - } - - return entry.data; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; } + + return entry.data; + } } const githubCache = new GitHubCache(); -const GITHUB_API_BASE = 'https://api.github.com'; -const REPO_OWNER = 'LukeGus'; -const REPO_NAME = 'Termix'; +const GITHUB_API_BASE = "https://api.github.com"; +const REPO_OWNER = "LukeGus"; +const REPO_NAME = "Termix"; interface GitHubRelease { + id: number; + tag_name: string; + name: string; + body: string; + published_at: string; + html_url: string; + assets: Array<{ id: number; - tag_name: string; name: string; - body: string; - published_at: string; - html_url: string; - assets: Array<{ - id: number; - name: string; - size: number; - download_count: number; - browser_download_url: string; - }>; - prerelease: boolean; - draft: boolean; + size: number; + download_count: number; + browser_download_url: string; + }>; + prerelease: boolean; + draft: boolean; } -async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise { - const cachedData = githubCache.get(cacheKey); - if (cachedData) { - return { - data: cachedData, - cached: true, - cache_age: Date.now() - cachedData.timestamp - }; +async function fetchGitHubAPI( + endpoint: string, + cacheKey: string, +): Promise { + const cachedData = githubCache.get(cacheKey); + if (cachedData) { + return { + data: cachedData, + cached: true, + cache_age: Date.now() - cachedData.timestamp, + }; + } + + try { + const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "TermixUpdateChecker/1.0", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); } - try { - const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { - headers: { - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'TermixUpdateChecker/1.0', - 'X-GitHub-Api-Version': '2022-11-28' - } - }); + const data = await response.json(); + githubCache.set(cacheKey, data); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - githubCache.set(cacheKey, data); - - return { - data: data, - cached: false - }; - } catch (error) { - databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint}); - throw error; - } + return { + data: data, + cached: false, + }; + } catch (error) { + databaseLogger.error(`Failed to fetch from GitHub API`, error, { + operation: "github_api", + endpoint, + }); + throw error; + } } app.use(bodyParser.json()); -app.get('/health', (req, res) => { - res.json({status: 'ok'}); +app.get("/health", (req, res) => { + res.json({ status: "ok" }); }); -app.get('/version', async (req, res) => { - let localVersion = process.env.VERSION; - - if (!localVersion) { - try { - const packagePath = path.resolve(process.cwd(), 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - localVersion = packageJson.version; - } catch (error) { - databaseLogger.error('Failed to read version from package.json', error, {operation: 'version_check'}); - } - } - - if (!localVersion) { - databaseLogger.error('No version information available', undefined, {operation: 'version_check'}); - return res.status(404).send('Local Version Not Set'); - } +app.get("/version", async (req, res) => { + let localVersion = process.env.VERSION; + if (!localVersion) { try { - const cacheKey = 'latest_release'; - const releaseData = await fetchGitHubAPI( - `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, - cacheKey - ); - - const rawTag = releaseData.data.tag_name || releaseData.data.name || ''; - const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); - const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; - - if (!remoteVersion) { - databaseLogger.warn('Remote version not found in GitHub response', {operation: 'version_check', rawTag}); - return res.status(401).send('Remote Version Not Found'); - } - - const isUpToDate = localVersion === remoteVersion; - - const response = { - status: isUpToDate ? 'up_to_date' : 'requires_update', - localVersion: localVersion, - version: remoteVersion, - latest_release: { - tag_name: releaseData.data.tag_name, - name: releaseData.data.name, - published_at: releaseData.data.published_at, - html_url: releaseData.data.html_url - }, - cached: releaseData.cached, - cache_age: releaseData.cache_age - }; - - res.json(response); - } catch (err) { - databaseLogger.error('Version check failed', err, {operation: 'version_check'}); - res.status(500).send('Fetch Error'); - } -}); - -app.get('/releases/rss', async (req, res) => { - try { - const page = parseInt(req.query.page as string) || 1; - const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100); - const cacheKey = `releases_rss_${page}_${per_page}`; - - const releasesData = await fetchGitHubAPI( - `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, - cacheKey - ); - - const rssItems = releasesData.data.map((release: GitHubRelease) => ({ - id: release.id, - title: release.name || release.tag_name, - description: release.body, - link: release.html_url, - pubDate: release.published_at, - version: release.tag_name, - isPrerelease: release.prerelease, - isDraft: release.draft, - assets: release.assets.map(asset => ({ - name: asset.name, - size: asset.size, - download_count: asset.download_count, - download_url: asset.browser_download_url - })) - })); - - const response = { - feed: { - title: `${REPO_NAME} Releases`, - description: `Latest releases from ${REPO_NAME} repository`, - link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`, - updated: new Date().toISOString() - }, - items: rssItems, - total_count: rssItems.length, - cached: releasesData.cached, - cache_age: releasesData.cache_age - }; - - res.json(response); + const packagePath = path.resolve(process.cwd(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + localVersion = packageJson.version; } catch (error) { - databaseLogger.error('Failed to generate RSS format', error, {operation: 'rss_releases'}); - res.status(500).json({ - error: 'Failed to generate RSS format', - details: error instanceof Error ? error.message : 'Unknown error' - }); + databaseLogger.error("Failed to read version from package.json", error, { + operation: "version_check", + }); } -}); + } - -app.use('/users', userRoutes); -app.use('/ssh', sshRoutes); -app.use('/alerts', alertRoutes); -app.use('/credentials', credentialsRoutes); - -app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { - apiLogger.error('Unhandled error in request', err, { - operation: 'error_handler', - method: req.method, - url: req.url, - userAgent: req.get('User-Agent') + if (!localVersion) { + databaseLogger.error("No version information available", undefined, { + operation: "version_check", }); - res.status(500).json({error: 'Internal Server Error'}); + return res.status(404).send("Local Version Not Set"); + } + + try { + const cacheKey = "latest_release"; + const releaseData = await fetchGitHubAPI( + `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, + cacheKey, + ); + + const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; + const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); + const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; + + if (!remoteVersion) { + databaseLogger.warn("Remote version not found in GitHub response", { + operation: "version_check", + rawTag, + }); + return res.status(401).send("Remote Version Not Found"); + } + + const isUpToDate = localVersion === remoteVersion; + + const response = { + status: isUpToDate ? "up_to_date" : "requires_update", + localVersion: localVersion, + version: remoteVersion, + latest_release: { + tag_name: releaseData.data.tag_name, + name: releaseData.data.name, + published_at: releaseData.data.published_at, + html_url: releaseData.data.html_url, + }, + cached: releaseData.cached, + cache_age: releaseData.cache_age, + }; + + res.json(response); + } catch (err) { + databaseLogger.error("Version check failed", err, { + operation: "version_check", + }); + res.status(500).send("Fetch Error"); + } }); +app.get("/releases/rss", async (req, res) => { + try { + const page = parseInt(req.query.page as string) || 1; + const per_page = Math.min( + parseInt(req.query.per_page as string) || 20, + 100, + ); + const cacheKey = `releases_rss_${page}_${per_page}`; + + const releasesData = await fetchGitHubAPI( + `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, + cacheKey, + ); + + const rssItems = releasesData.data.map((release: GitHubRelease) => ({ + id: release.id, + title: release.name || release.tag_name, + description: release.body, + link: release.html_url, + pubDate: release.published_at, + version: release.tag_name, + isPrerelease: release.prerelease, + isDraft: release.draft, + assets: release.assets.map((asset) => ({ + name: asset.name, + size: asset.size, + download_count: asset.download_count, + download_url: asset.browser_download_url, + })), + })); + + const response = { + feed: { + title: `${REPO_NAME} Releases`, + description: `Latest releases from ${REPO_NAME} repository`, + link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`, + updated: new Date().toISOString(), + }, + items: rssItems, + total_count: rssItems.length, + cached: releasesData.cached, + cache_age: releasesData.cache_age, + }; + + res.json(response); + } catch (error) { + databaseLogger.error("Failed to generate RSS format", error, { + operation: "rss_releases", + }); + res.status(500).json({ + error: "Failed to generate RSS format", + details: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.use("/users", userRoutes); +app.use("/ssh", sshRoutes); +app.use("/alerts", alertRoutes); +app.use("/credentials", credentialsRoutes); + +app.use( + ( + err: unknown, + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + apiLogger.error("Unhandled error in request", err, { + operation: "error_handler", + method: req.method, + url: req.url, + userAgent: req.get("User-Agent"), + }); + res.status(500).json({ error: "Internal Server Error" }); + }, +); + const PORT = 8081; app.listen(PORT, () => { - databaseLogger.success(`Database API server started on port ${PORT}`, { - operation: 'server_start', - port: PORT, - routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] - }); -}); \ No newline at end of file + databaseLogger.success(`Database API server started on port ${PORT}`, { + operation: "server_start", + port: PORT, + routes: [ + "/users", + "/ssh", + "/alerts", + "/credentials", + "/health", + "/version", + "/releases/rss", + ], + }); +}); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 58551910..1dd17218 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1,19 +1,25 @@ -import {drizzle} from 'drizzle-orm/better-sqlite3'; -import Database from 'better-sqlite3'; -import * as schema from './schema.js'; -import fs from 'fs'; -import path from 'path'; -import { databaseLogger } from '../../utils/logger.js'; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import * as schema from "./schema.js"; +import fs from "fs"; +import path from "path"; +import { databaseLogger } from "../../utils/logger.js"; -const dataDir = process.env.DATA_DIR || './db/data'; +const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); if (!fs.existsSync(dbDir)) { - databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir }); - fs.mkdirSync(dbDir, {recursive: true}); + databaseLogger.info(`Creating database directory`, { + operation: "db_init", + path: dbDir, + }); + fs.mkdirSync(dbDir, { recursive: true }); } -const dbPath = path.join(dataDir, 'db.sqlite'); -databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath }); +const dbPath = path.join(dataDir, "db.sqlite"); +databaseLogger.info(`Initializing SQLite database`, { + operation: "db_init", + path: dbPath, +}); const sqlite = new Database(dbPath); sqlite.exec(` @@ -137,90 +143,164 @@ sqlite.exec(` ); `); -const addColumnIfNotExists = (table: string, column: string, definition: string) => { +const addColumnIfNotExists = ( + table: string, + column: string, + definition: string, +) => { + try { + sqlite + .prepare( + `SELECT ${column} + FROM ${table} LIMIT 1`, + ) + .get(); + } catch (e) { try { - sqlite.prepare(`SELECT ${column} - FROM ${table} LIMIT 1`).get(); - } catch (e) { - try { - databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column }); - sqlite.exec(`ALTER TABLE ${table} + databaseLogger.debug(`Adding column ${column} to ${table}`, { + operation: "schema_migration", + table, + column, + }); + sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); - databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column }); - } catch (alterError) { - databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError }); - } + databaseLogger.success(`Column ${column} added to ${table}`, { + operation: "schema_migration", + table, + column, + }); + } catch (alterError) { + databaseLogger.warn(`Failed to add column ${column} to ${table}`, { + operation: "schema_migration", + table, + column, + error: alterError, + }); } + } }; const migrateSchema = () => { - databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' }); + databaseLogger.info("Checking for schema updates...", { + operation: "schema_migration", + }); - addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); + addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); - addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0'); - addColumnIfNotExists('users', 'oidc_identifier', 'TEXT'); - addColumnIfNotExists('users', 'client_id', 'TEXT'); - addColumnIfNotExists('users', 'client_secret', 'TEXT'); - addColumnIfNotExists('users', 'issuer_url', 'TEXT'); - addColumnIfNotExists('users', 'authorization_url', 'TEXT'); - addColumnIfNotExists('users', 'token_url', 'TEXT'); + addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists("users", "oidc_identifier", "TEXT"); + addColumnIfNotExists("users", "client_id", "TEXT"); + addColumnIfNotExists("users", "client_secret", "TEXT"); + addColumnIfNotExists("users", "issuer_url", "TEXT"); + addColumnIfNotExists("users", "authorization_url", "TEXT"); + addColumnIfNotExists("users", "token_url", "TEXT"); - addColumnIfNotExists('users', 'identifier_path', 'TEXT'); - addColumnIfNotExists('users', 'name_path', 'TEXT'); - addColumnIfNotExists('users', 'scopes', 'TEXT'); + addColumnIfNotExists("users", "identifier_path", "TEXT"); + addColumnIfNotExists("users", "name_path", "TEXT"); + addColumnIfNotExists("users", "scopes", "TEXT"); - addColumnIfNotExists('users', 'totp_secret', 'TEXT'); - addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0'); - addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT'); + addColumnIfNotExists("users", "totp_secret", "TEXT"); + addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); - addColumnIfNotExists('ssh_data', 'name', 'TEXT'); - addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); - addColumnIfNotExists('ssh_data', 'tags', 'TEXT'); - addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0'); - addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"'); - addColumnIfNotExists('ssh_data', 'password', 'TEXT'); - addColumnIfNotExists('ssh_data', 'key', 'TEXT'); - addColumnIfNotExists('ssh_data', 'key_password', 'TEXT'); - addColumnIfNotExists('ssh_data', 'key_type', 'TEXT'); - addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1'); - addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1'); - addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT'); - addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1'); - addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); - addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); - addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); + addColumnIfNotExists("ssh_data", "name", "TEXT"); + addColumnIfNotExists("ssh_data", "folder", "TEXT"); + addColumnIfNotExists("ssh_data", "tags", "TEXT"); + addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists( + "ssh_data", + "auth_type", + 'TEXT NOT NULL DEFAULT "password"', + ); + addColumnIfNotExists("ssh_data", "password", "TEXT"); + addColumnIfNotExists("ssh_data", "key", "TEXT"); + addColumnIfNotExists("ssh_data", "key_password", "TEXT"); + addColumnIfNotExists("ssh_data", "key_type", "TEXT"); + addColumnIfNotExists( + "ssh_data", + "enable_terminal", + "INTEGER NOT NULL DEFAULT 1", + ); + addColumnIfNotExists( + "ssh_data", + "enable_tunnel", + "INTEGER NOT NULL DEFAULT 1", + ); + addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT"); + addColumnIfNotExists( + "ssh_data", + "enable_file_manager", + "INTEGER NOT NULL DEFAULT 1", + ); + addColumnIfNotExists("ssh_data", "default_path", "TEXT"); + addColumnIfNotExists( + "ssh_data", + "created_at", + "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", + ); + addColumnIfNotExists( + "ssh_data", + "updated_at", + "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", + ); - addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)'); + addColumnIfNotExists( + "ssh_data", + "credential_id", + "INTEGER REFERENCES ssh_credentials(id)", + ); - addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL'); - addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL'); - addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL'); + addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL"); + addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); + addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); - databaseLogger.success('Schema migration completed', { operation: 'schema_migration' }); + databaseLogger.success("Schema migration completed", { + operation: "schema_migration", + }); }; const initializeDatabase = async () => { - migrateSchema(); + migrateSchema(); - try { - const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); - if (!row) { - databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' }); - sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); - databaseLogger.success('Default settings initialized', { operation: 'db_init' }); - } else { - databaseLogger.debug('Default settings already exist', { operation: 'db_init' }); - } - } catch (e) { - databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e }); + try { + const row = sqlite + .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") + .get(); + if (!row) { + databaseLogger.info("Initializing default settings", { + operation: "db_init", + setting: "allow_registration", + }); + sqlite + .prepare( + "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')", + ) + .run(); + databaseLogger.success("Default settings initialized", { + operation: "db_init", + }); + } else { + databaseLogger.debug("Default settings already exist", { + operation: "db_init", + }); } + } catch (e) { + databaseLogger.warn("Could not initialize default settings", { + operation: "db_init", + error: e, + }); + } }; -initializeDatabase().catch(error => { - databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' }); - process.exit(1); +initializeDatabase().catch((error) => { + databaseLogger.error("Failed to initialize database", error, { + operation: "db_init", + }); + process.exit(1); }); -databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath }); -export const db = drizzle(sqlite, {schema}); \ No newline at end of file +databaseLogger.success("Database connection established", { + operation: "db_init", + path: dbPath, +}); +export const db = drizzle(sqlite, { schema }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index e643f668..9e46d73a 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,117 +1,167 @@ -import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; -import {sql} from 'drizzle-orm'; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - username: text('username').notNull(), - password_hash: text('password_hash').notNull(), - is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false), +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + username: text("username").notNull(), + password_hash: text("password_hash").notNull(), + is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false), - is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false), - oidc_identifier: text('oidc_identifier'), - client_id: text('client_id'), - client_secret: text('client_secret'), - issuer_url: text('issuer_url'), - authorization_url: text('authorization_url'), - token_url: text('token_url'), - identifier_path: text('identifier_path'), - name_path: text('name_path'), - scopes: text().default("openid email profile"), + is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false), + oidc_identifier: text("oidc_identifier"), + client_id: text("client_id"), + client_secret: text("client_secret"), + issuer_url: text("issuer_url"), + authorization_url: text("authorization_url"), + token_url: text("token_url"), + identifier_path: text("identifier_path"), + name_path: text("name_path"), + scopes: text().default("openid email profile"), - totp_secret: text('totp_secret'), - totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), - totp_backup_codes: text('totp_backup_codes'), + totp_secret: text("totp_secret"), + totp_enabled: integer("totp_enabled", { mode: "boolean" }) + .notNull() + .default(false), + totp_backup_codes: text("totp_backup_codes"), }); -export const settings = sqliteTable('settings', { - key: text('key').primaryKey(), - value: text('value').notNull(), +export const settings = sqliteTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), }); -export const sshData = sqliteTable('ssh_data', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - name: text('name'), - ip: text('ip').notNull(), - port: integer('port').notNull(), - username: text('username').notNull(), - folder: text('folder'), - tags: text('tags'), - pin: integer('pin', {mode: 'boolean'}).notNull().default(false), - authType: text('auth_type').notNull(), +export const sshData = sqliteTable("ssh_data", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name"), + ip: text("ip").notNull(), + port: integer("port").notNull(), + username: text("username").notNull(), + folder: text("folder"), + tags: text("tags"), + pin: integer("pin", { mode: "boolean" }).notNull().default(false), + authType: text("auth_type").notNull(), - password: text('password'), - key: text('key', {length: 8192}), - keyPassword: text('key_password'), - keyType: text('key_type'), + password: text("password"), + key: text("key", { length: 8192 }), + keyPassword: text("key_password"), + keyType: text("key_type"), - credentialId: integer('credential_id').references(() => sshCredentials.id), - enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), - enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), - tunnelConnections: text('tunnel_connections'), - enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true), - defaultPath: text('default_path'), - createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), - updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), + credentialId: integer("credential_id").references(() => sshCredentials.id), + enableTerminal: integer("enable_terminal", { mode: "boolean" }) + .notNull() + .default(true), + enableTunnel: integer("enable_tunnel", { mode: "boolean" }) + .notNull() + .default(true), + tunnelConnections: text("tunnel_connections"), + enableFileManager: integer("enable_file_manager", { mode: "boolean" }) + .notNull() + .default(true), + defaultPath: text("default_path"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const fileManagerRecent = sqliteTable('file_manager_recent', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - hostId: integer('host_id').notNull().references(() => sshData.id), - name: text('name').notNull(), - path: text('path').notNull(), - lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`), +export const fileManagerRecent = sqliteTable("file_manager_recent", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + name: text("name").notNull(), + path: text("path").notNull(), + lastOpened: text("last_opened") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const fileManagerPinned = sqliteTable('file_manager_pinned', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - hostId: integer('host_id').notNull().references(() => sshData.id), - name: text('name').notNull(), - path: text('path').notNull(), - pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`), +export const fileManagerPinned = sqliteTable("file_manager_pinned", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + name: text("name").notNull(), + path: text("path").notNull(), + pinnedAt: text("pinned_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - hostId: integer('host_id').notNull().references(() => sshData.id), - name: text('name').notNull(), - path: text('path').notNull(), - createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + name: text("name").notNull(), + path: text("path").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const dismissedAlerts = sqliteTable('dismissed_alerts', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - alertId: text('alert_id').notNull(), - dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), +export const dismissedAlerts = sqliteTable("dismissed_alerts", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + alertId: text("alert_id").notNull(), + dismissedAt: text("dismissed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const sshCredentials = sqliteTable('ssh_credentials', { - id: integer('id').primaryKey({autoIncrement: true}), - userId: text('user_id').notNull().references(() => users.id), - name: text('name').notNull(), - description: text('description'), - folder: text('folder'), - tags: text('tags'), - authType: text('auth_type').notNull(), - username: text('username').notNull(), - password: text('password'), - key: text('key', {length: 16384}), - keyPassword: text('key_password'), - keyType: text('key_type'), - usageCount: integer('usage_count').notNull().default(0), - lastUsed: text('last_used'), - createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), - updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), +export const sshCredentials = sqliteTable("ssh_credentials", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + authType: text("auth_type").notNull(), + username: text("username").notNull(), + password: text("password"), + key: text("key", { length: 16384 }), + keyPassword: text("key_password"), + keyType: text("key_type"), + usageCount: integer("usage_count").notNull().default(0), + lastUsed: text("last_used"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); -export const sshCredentialUsage = sqliteTable('ssh_credential_usage', { - id: integer('id').primaryKey({autoIncrement: true}), - credentialId: integer('credential_id').notNull().references(() => sshCredentials.id), - hostId: integer('host_id').notNull().references(() => sshData.id), - userId: text('user_id').notNull().references(() => users.id), - usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`), -}); \ No newline at end of file +export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { + id: integer("id").primaryKey({ autoIncrement: true }), + credentialId: integer("credential_id") + .notNull() + .references(() => sshCredentials.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + userId: text("user_id") + .notNull() + .references(() => users.id), + usedAt: text("used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index bb95dad7..ddfc44c5 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -1,248 +1,261 @@ -import express from 'express'; -import {db} from '../db/index.js'; -import {dismissedAlerts} from '../db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import fetch from 'node-fetch'; -import {authLogger} from '../../utils/logger.js'; - +import express from "express"; +import { db } from "../db/index.js"; +import { dismissedAlerts } from "../db/schema.js"; +import { eq, and } from "drizzle-orm"; +import fetch from "node-fetch"; +import { authLogger } from "../../utils/logger.js"; interface CacheEntry { - data: any; - timestamp: number; - expiresAt: number; + data: any; + timestamp: number; + expiresAt: number; } class AlertCache { - private cache: Map = new Map(); - private readonly CACHE_DURATION = 5 * 60 * 1000; + private cache: Map = new Map(); + private readonly CACHE_DURATION = 5 * 60 * 1000; - set(key: string, data: any): void { - const now = Date.now(); - this.cache.set(key, { - data, - timestamp: now, - expiresAt: now + this.CACHE_DURATION - }); + set(key: string, data: any): void { + const now = Date.now(); + this.cache.set(key, { + data, + timestamp: now, + expiresAt: now + this.CACHE_DURATION, + }); + } + + get(key: string): any | null { + const entry = this.cache.get(key); + if (!entry) { + return null; } - get(key: string): any | null { - const entry = this.cache.get(key); - if (!entry) { - return null; - } - - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return null; - } - - return entry.data; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; } + + return entry.data; + } } const alertCache = new AlertCache(); -const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; -const REPO_OWNER = 'LukeGus'; -const REPO_NAME = 'Termix-Docs'; -const ALERTS_FILE = 'main/termix-alerts.json'; +const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"; +const REPO_OWNER = "LukeGus"; +const REPO_NAME = "Termix-Docs"; +const ALERTS_FILE = "main/termix-alerts.json"; interface TermixAlert { - id: string; - title: string; - message: string; - expiresAt: string; - priority?: 'low' | 'medium' | 'high' | 'critical'; - type?: 'info' | 'warning' | 'error' | 'success'; - actionUrl?: string; - actionText?: string; + id: string; + title: string; + message: string; + expiresAt: string; + priority?: "low" | "medium" | "high" | "critical"; + type?: "info" | "warning" | "error" | "success"; + actionUrl?: string; + actionText?: string; } async function fetchAlertsFromGitHub(): Promise { - const cacheKey = 'termix_alerts'; - const cachedData = alertCache.get(cacheKey); - if (cachedData) { - return cachedData; + const cacheKey = "termix_alerts"; + const cachedData = alertCache.get(cacheKey); + if (cachedData) { + return cachedData; + } + try { + const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; + + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "TermixAlertChecker/1.0", + }, + }); + + if (!response.ok) { + authLogger.warn("GitHub API returned error status", { + operation: "alerts_fetch", + status: response.status, + statusText: response.statusText, + }); + throw new Error( + `GitHub raw content error: ${response.status} ${response.statusText}`, + ); } - try { - const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; - const response = await fetch(url, { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'TermixAlertChecker/1.0' - } - }); + const alerts: TermixAlert[] = (await response.json()) as TermixAlert[]; - if (!response.ok) { - authLogger.warn('GitHub API returned error status', { - operation: 'alerts_fetch', - status: response.status, - statusText: response.statusText - }); - throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`); - } + const now = new Date(); - const alerts: TermixAlert[] = await response.json() as TermixAlert[]; + const validAlerts = alerts.filter((alert) => { + const expiryDate = new Date(alert.expiresAt); + const isValid = expiryDate > now; + return isValid; + }); - const now = new Date(); - - const validAlerts = alerts.filter(alert => { - const expiryDate = new Date(alert.expiresAt); - const isValid = expiryDate > now; - return isValid; - }); - - alertCache.set(cacheKey, validAlerts); - return validAlerts; - } catch (error) { - authLogger.error('Failed to fetch alerts from GitHub', { - operation: 'alerts_fetch', - error: error instanceof Error ? error.message : 'Unknown error' - }); - return []; - } + alertCache.set(cacheKey, validAlerts); + return validAlerts; + } catch (error) { + authLogger.error("Failed to fetch alerts from GitHub", { + operation: "alerts_fetch", + error: error instanceof Error ? error.message : "Unknown error", + }); + return []; + } } const router = express.Router(); // Route: Get all active alerts // GET /alerts -router.get('/', async (req, res) => { - try { - const alerts = await fetchAlertsFromGitHub(); - res.json({ - alerts, - cached: alertCache.get('termix_alerts') !== null, - total_count: alerts.length - }); - } catch (error) { - authLogger.error('Failed to get alerts', error); - res.status(500).json({error: 'Failed to fetch alerts'}); - } +router.get("/", async (req, res) => { + try { + const alerts = await fetchAlertsFromGitHub(); + res.json({ + alerts, + cached: alertCache.get("termix_alerts") !== null, + total_count: alerts.length, + }); + } catch (error) { + authLogger.error("Failed to get alerts", error); + res.status(500).json({ error: "Failed to fetch alerts" }); + } }); // Route: Get alerts for a specific user (excluding dismissed ones) // GET /alerts/user/:userId -router.get('/user/:userId', async (req, res) => { - try { - const {userId} = req.params; +router.get("/user/:userId", async (req, res) => { + try { + const { userId } = req.params; - if (!userId) { - return res.status(400).json({error: 'User ID is required'}); - } - - const allAlerts = await fetchAlertsFromGitHub(); - - const dismissedAlertRecords = await db - .select({alertId: dismissedAlerts.alertId}) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - - const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId)); - - const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id)); - - res.json({ - alerts: userAlerts, - total_count: userAlerts.length, - dismissed_count: dismissedAlertIds.size - }); - } catch (error) { - authLogger.error('Failed to get user alerts', error); - res.status(500).json({error: 'Failed to fetch user alerts'}); + if (!userId) { + return res.status(400).json({ error: "User ID is required" }); } + + const allAlerts = await fetchAlertsFromGitHub(); + + const dismissedAlertRecords = await db + .select({ alertId: dismissedAlerts.alertId }) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + const dismissedAlertIds = new Set( + dismissedAlertRecords.map((record) => record.alertId), + ); + + const userAlerts = allAlerts.filter( + (alert) => !dismissedAlertIds.has(alert.id), + ); + + res.json({ + alerts: userAlerts, + total_count: userAlerts.length, + dismissed_count: dismissedAlertIds.size, + }); + } catch (error) { + authLogger.error("Failed to get user alerts", error); + res.status(500).json({ error: "Failed to fetch user alerts" }); + } }); // Route: Dismiss an alert for a user // POST /alerts/dismiss -router.post('/dismiss', async (req, res) => { - try { - const {userId, alertId} = req.body; +router.post("/dismiss", async (req, res) => { + try { + const { userId, alertId } = req.body; - if (!userId || !alertId) { - authLogger.warn('Missing userId or alertId in dismiss request'); - return res.status(400).json({error: 'User ID and Alert ID are required'}); - } - - const existingDismissal = await db - .select() - .from(dismissedAlerts) - .where(and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId) - )); - - if (existingDismissal.length > 0) { - authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); - return res.status(409).json({error: 'Alert already dismissed'}); - } - - const result = await db.insert(dismissedAlerts).values({ - userId, - alertId - }); - - res.json({message: 'Alert dismissed successfully'}); - } catch (error) { - authLogger.error('Failed to dismiss alert', error); - res.status(500).json({error: 'Failed to dismiss alert'}); + if (!userId || !alertId) { + authLogger.warn("Missing userId or alertId in dismiss request"); + return res + .status(400) + .json({ error: "User ID and Alert ID are required" }); } + + const existingDismissal = await db + .select() + .from(dismissedAlerts) + .where( + and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId), + ), + ); + + if (existingDismissal.length > 0) { + authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); + return res.status(409).json({ error: "Alert already dismissed" }); + } + + const result = await db.insert(dismissedAlerts).values({ + userId, + alertId, + }); + + res.json({ message: "Alert dismissed successfully" }); + } catch (error) { + authLogger.error("Failed to dismiss alert", error); + res.status(500).json({ error: "Failed to dismiss alert" }); + } }); // Route: Get dismissed alerts for a user // GET /alerts/dismissed/:userId -router.get('/dismissed/:userId', async (req, res) => { - try { - const {userId} = req.params; +router.get("/dismissed/:userId", async (req, res) => { + try { + const { userId } = req.params; - if (!userId) { - return res.status(400).json({error: 'User ID is required'}); - } - - const dismissedAlertRecords = await db - .select({ - alertId: dismissedAlerts.alertId, - dismissedAt: dismissedAlerts.dismissedAt - }) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - - res.json({ - dismissed_alerts: dismissedAlertRecords, - total_count: dismissedAlertRecords.length - }); - } catch (error) { - authLogger.error('Failed to get dismissed alerts', error); - res.status(500).json({error: 'Failed to fetch dismissed alerts'}); + if (!userId) { + return res.status(400).json({ error: "User ID is required" }); } + + const dismissedAlertRecords = await db + .select({ + alertId: dismissedAlerts.alertId, + dismissedAt: dismissedAlerts.dismissedAt, + }) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + res.json({ + dismissed_alerts: dismissedAlertRecords, + total_count: dismissedAlertRecords.length, + }); + } catch (error) { + authLogger.error("Failed to get dismissed alerts", error); + res.status(500).json({ error: "Failed to fetch dismissed alerts" }); + } }); // Route: Undismiss an alert for a user (remove from dismissed list) // DELETE /alerts/dismiss -router.delete('/dismiss', async (req, res) => { - try { - const {userId, alertId} = req.body; +router.delete("/dismiss", async (req, res) => { + try { + const { userId, alertId } = req.body; - if (!userId || !alertId) { - return res.status(400).json({error: 'User ID and Alert ID are required'}); - } - - const result = await db - .delete(dismissedAlerts) - .where(and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId) - )); - - if (result.changes === 0) { - return res.status(404).json({error: 'Dismissed alert not found'}); - } - res.json({message: 'Alert undismissed successfully'}); - } catch (error) { - authLogger.error('Failed to undismiss alert', error); - res.status(500).json({error: 'Failed to undismiss alert'}); + if (!userId || !alertId) { + return res + .status(400) + .json({ error: "User ID and Alert ID are required" }); } + + const result = await db + .delete(dismissedAlerts) + .where( + and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId), + ), + ); + + if (result.changes === 0) { + return res.status(404).json({ error: "Dismissed alert not found" }); + } + res.json({ message: "Alert undismissed successfully" }); + } catch (error) { + authLogger.error("Failed to undismiss alert", error); + res.status(500).json({ error: "Failed to undismiss alert" }); + } }); export default router; diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 62ad0be4..b6dbb62c 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1,576 +1,664 @@ -import express from 'express'; -import {db} from '../db/index.js'; -import {sshCredentials, sshCredentialUsage, sshData} from '../db/schema.js'; -import {eq, and, desc, sql} from 'drizzle-orm'; -import type {Request, Response, NextFunction} from 'express'; -import jwt from 'jsonwebtoken'; -import {authLogger} from '../../utils/logger.js'; - +import express from "express"; +import { db } from "../db/index.js"; +import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js"; +import { eq, and, desc, sql } from "drizzle-orm"; +import type { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { authLogger } from "../../utils/logger.js"; const router = express.Router(); interface JWTPayload { - userId: string; - iat?: number; - exp?: number; + userId: string; + iat?: number; + exp?: number; } function isNonEmptyString(val: any): val is string { - return typeof val === 'string' && val.trim().length > 0; + return typeof val === "string" && val.trim().length > 0; } function authenticateJWT(req: Request, res: Response, next: NextFunction) { - const authHeader = req.headers['authorization']; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - authLogger.warn('Missing or invalid Authorization header'); - return res.status(401).json({error: 'Missing or invalid Authorization header'}); - } - const token = authHeader.split(' ')[1]; - const jwtSecret = process.env.JWT_SECRET || 'secret'; - try { - const payload = jwt.verify(token, jwtSecret) as JWTPayload; - (req as any).userId = payload.userId; - next(); - } catch (err) { - authLogger.warn('Invalid or expired token'); - return res.status(401).json({error: 'Invalid or expired token'}); - } + const authHeader = req.headers["authorization"]; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + authLogger.warn("Missing or invalid Authorization header"); + return res + .status(401) + .json({ error: "Missing or invalid Authorization header" }); + } + const token = authHeader.split(" ")[1]; + const jwtSecret = process.env.JWT_SECRET || "secret"; + try { + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + (req as any).userId = payload.userId; + next(); + } catch (err) { + authLogger.warn("Invalid or expired token"); + return res.status(401).json({ error: "Invalid or expired token" }); + } } // Create a new credential // POST /credentials -router.post('/', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const { +router.post("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { + name, + description, + folder, + tags, + authType, + username, + password, + key, + keyPassword, + keyType, + } = req.body; + + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(name) || + !isNonEmptyString(username) + ) { + authLogger.warn("Invalid credential creation data validation failed", { + operation: "credential_create", + userId, + hasName: !!name, + hasUsername: !!username, + }); + return res.status(400).json({ error: "Name and username are required" }); + } + + if (!["password", "key"].includes(authType)) { + authLogger.warn("Invalid auth type provided", { + operation: "credential_create", + userId, + name, + authType, + }); + return res + .status(400) + .json({ error: 'Auth type must be "password" or "key"' }); + } + + try { + if (authType === "password" && !password) { + authLogger.warn("Password required for password authentication", { + operation: "credential_create", + userId, + name, + authType, + }); + return res + .status(400) + .json({ error: "Password is required for password authentication" }); + } + if (authType === "key" && !key) { + authLogger.warn("SSH key required for key authentication", { + operation: "credential_create", + userId, + name, + authType, + }); + return res + .status(400) + .json({ error: "SSH key is required for key authentication" }); + } + const plainPassword = authType === "password" && password ? password : null; + const plainKey = authType === "key" && key ? key : null; + const plainKeyPassword = + authType === "key" && keyPassword ? keyPassword : null; + + const credentialData = { + userId, + name: name.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + authType, + username: username.trim(), + password: plainPassword, + key: plainKey, + keyPassword: plainKeyPassword, + keyType: keyType || null, + usageCount: 0, + lastUsed: null, + }; + + const result = await db + .insert(sshCredentials) + .values(credentialData) + .returning(); + const created = result[0]; + + authLogger.success( + `SSH credential created: ${name} (${authType}) by user ${userId}`, + { + operation: "credential_create_success", + userId, + credentialId: created.id, name, - description, - folder, - tags, authType, username, - password, - key, - keyPassword, - keyType - } = req.body; + }, + ); - if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { - authLogger.warn('Invalid credential creation data validation failed', { - operation: 'credential_create', - userId, - hasName: !!name, - hasUsername: !!username - }); - return res.status(400).json({error: 'Name and username are required'}); - } - - if (!['password', 'key'].includes(authType)) { - authLogger.warn('Invalid auth type provided', {operation: 'credential_create', userId, name, authType}); - return res.status(400).json({error: 'Auth type must be "password" or "key"'}); - } - - try { - if (authType === 'password' && !password) { - authLogger.warn('Password required for password authentication', { - operation: 'credential_create', - userId, - name, - authType - }); - return res.status(400).json({error: 'Password is required for password authentication'}); - } - if (authType === 'key' && !key) { - authLogger.warn('SSH key required for key authentication', { - operation: 'credential_create', - userId, - name, - authType - }); - return res.status(400).json({error: 'SSH key is required for key authentication'}); - } - const plainPassword = (authType === 'password' && password) ? password : null; - const plainKey = (authType === 'key' && key) ? key : null; - const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null; - - const credentialData = { - userId, - name: name.trim(), - description: description?.trim() || null, - folder: folder?.trim() || null, - tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), - authType, - username: username.trim(), - password: plainPassword, - key: plainKey, - keyPassword: plainKeyPassword, - keyType: keyType || null, - usageCount: 0, - lastUsed: null, - }; - - const result = await db.insert(sshCredentials).values(credentialData).returning(); - const created = result[0]; - - authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { - operation: 'credential_create_success', - userId, - credentialId: created.id, - name, - authType, - username - }); - - res.status(201).json(formatCredentialOutput(created)); - } catch (err) { - authLogger.error('Failed to create credential in database', err, { - operation: 'credential_create', - userId, - name, - authType, - username - }); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to create credential' - }); - } + res.status(201).json(formatCredentialOutput(created)); + } catch (err) { + authLogger.error("Failed to create credential in database", err, { + operation: "credential_create", + userId, + name, + authType, + username, + }); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to create credential", + }); + } }); // Get all credentials for the authenticated user // GET /credentials -router.get('/', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; +router.get("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - authLogger.warn('Invalid userId for credential fetch'); - return res.status(400).json({error: 'Invalid userId'}); - } + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for credential fetch"); + return res.status(400).json({ error: "Invalid userId" }); + } - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .orderBy(desc(sshCredentials.updatedAt)); + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)) + .orderBy(desc(sshCredentials.updatedAt)); - res.json(credentials.map(cred => formatCredentialOutput(cred))); - } catch (err) { - authLogger.error('Failed to fetch credentials', err); - res.status(500).json({error: 'Failed to fetch credentials'}); - } + res.json(credentials.map((cred) => formatCredentialOutput(cred))); + } catch (err) { + authLogger.error("Failed to fetch credentials", err); + res.status(500).json({ error: "Failed to fetch credentials" }); + } }); // Get all unique credential folders for the authenticated user // GET /credentials/folders -router.get('/folders', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; +router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - authLogger.warn('Invalid userId for credential folder fetch'); - return res.status(400).json({error: 'Invalid userId'}); - } + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for credential folder fetch"); + return res.status(400).json({ error: "Invalid userId" }); + } - try { - const result = await db - .select({folder: sshCredentials.folder}) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); + try { + const result = await db + .select({ folder: sshCredentials.folder }) + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); - const folderCounts: Record = {}; - result.forEach(r => { - if (r.folder && r.folder.trim() !== '') { - folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; - } - }); + const folderCounts: Record = {}; + result.forEach((r) => { + if (r.folder && r.folder.trim() !== "") { + folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; + } + }); - const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); - res.json(folders); - } catch (err) { - authLogger.error('Failed to fetch credential folders', err); - res.status(500).json({error: 'Failed to fetch credential folders'}); - } + const folders = Object.keys(folderCounts).filter( + (folder) => folderCounts[folder] > 0, + ); + res.json(folders); + } catch (err) { + authLogger.error("Failed to fetch credential folders", err); + res.status(500).json({ error: "Failed to fetch credential folders" }); + } }); // Get a specific credential by ID (with plain text secrets) // GET /credentials/:id -router.get('/:id', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {id} = req.params; +router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; - if (!isNonEmptyString(userId) || !id) { - authLogger.warn('Invalid request for credential fetch'); - return res.status(400).json({error: 'Invalid request'}); + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for credential fetch"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + ); + + if (credentials.length === 0) { + return res.status(404).json({ error: "Credential not found" }); } - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId) - )); + const credential = credentials[0]; + const output = formatCredentialOutput(credential); - if (credentials.length === 0) { - return res.status(404).json({error: 'Credential not found'}); - } - - const credential = credentials[0]; - const output = formatCredentialOutput(credential); - - if (credential.password) { - (output as any).password = credential.password; - } - if (credential.key) { - (output as any).key = credential.key; - } - if (credential.keyPassword) { - (output as any).keyPassword = credential.keyPassword; - } - - res.json(output); - } catch (err) { - authLogger.error('Failed to fetch credential', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to fetch credential' - }); + if (credential.password) { + (output as any).password = credential.password; } + if (credential.key) { + (output as any).key = credential.key; + } + if (credential.keyPassword) { + (output as any).keyPassword = credential.keyPassword; + } + + res.json(output); + } catch (err) { + authLogger.error("Failed to fetch credential", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch credential", + }); + } }); // Update a credential // PUT /credentials/:id -router.put('/:id', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {id} = req.params; - const updateData = req.body; +router.put("/:id", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; + const updateData = req.body; - if (!isNonEmptyString(userId) || !id) { - authLogger.warn('Invalid request for credential update'); - return res.status(400).json({error: 'Invalid request'}); + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for credential update"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Credential not found" }); } - try { - const existing = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId) - )); + const updateFields: any = {}; - if (existing.length === 0) { - return res.status(404).json({error: 'Credential not found'}); - } - - const updateFields: any = {}; - - if (updateData.name !== undefined) updateFields.name = updateData.name.trim(); - if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; - if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; - if (updateData.tags !== undefined) { - updateFields.tags = Array.isArray(updateData.tags) ? updateData.tags.join(',') : (updateData.tags || ''); - } - if (updateData.username !== undefined) updateFields.username = updateData.username.trim(); - if (updateData.authType !== undefined) updateFields.authType = updateData.authType; - if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType; - - if (updateData.password !== undefined) { - updateFields.password = updateData.password || null; - } - if (updateData.key !== undefined) { - updateFields.key = updateData.key || null; - } - if (updateData.keyPassword !== undefined) { - updateFields.keyPassword = updateData.keyPassword || null; - } - - if (Object.keys(updateFields).length === 0) { - const existing = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))); - - return res.json(formatCredentialOutput(existing[0])); - } - - await db - .update(sshCredentials) - .set(updateFields) - .where(and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId) - )); - - const updated = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))); - - const credential = updated[0]; - authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { - operation: 'credential_update_success', - userId, - credentialId: parseInt(id), - name: credential.name, - authType: credential.authType, - username: credential.username - }); - - res.json(formatCredentialOutput(updated[0])); - } catch (err) { - authLogger.error('Failed to update credential', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to update credential' - }); + if (updateData.name !== undefined) + updateFields.name = updateData.name.trim(); + if (updateData.description !== undefined) + updateFields.description = updateData.description?.trim() || null; + if (updateData.folder !== undefined) + updateFields.folder = updateData.folder?.trim() || null; + if (updateData.tags !== undefined) { + updateFields.tags = Array.isArray(updateData.tags) + ? updateData.tags.join(",") + : updateData.tags || ""; } + if (updateData.username !== undefined) + updateFields.username = updateData.username.trim(); + if (updateData.authType !== undefined) + updateFields.authType = updateData.authType; + if (updateData.keyType !== undefined) + updateFields.keyType = updateData.keyType; + + if (updateData.password !== undefined) { + updateFields.password = updateData.password || null; + } + if (updateData.key !== undefined) { + updateFields.key = updateData.key || null; + } + if (updateData.keyPassword !== undefined) { + updateFields.keyPassword = updateData.keyPassword || null; + } + + if (Object.keys(updateFields).length === 0) { + const existing = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, parseInt(id))); + + return res.json(formatCredentialOutput(existing[0])); + } + + await db + .update(sshCredentials) + .set(updateFields) + .where( + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + ); + + const updated = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, parseInt(id))); + + const credential = updated[0]; + authLogger.success( + `SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, + { + operation: "credential_update_success", + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username, + }, + ); + + res.json(formatCredentialOutput(updated[0])); + } catch (err) { + authLogger.error("Failed to update credential", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to update credential", + }); + } }); // Delete a credential // DELETE /credentials/:id -router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {id} = req.params; +router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; - if (!isNonEmptyString(userId) || !id) { - authLogger.warn('Invalid request for credential deletion'); - return res.status(400).json({error: 'Invalid request'}); + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for credential deletion"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const credentialToDelete = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + ); + + if (credentialToDelete.length === 0) { + return res.status(404).json({ error: "Credential not found" }); } - try { - const credentialToDelete = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId) - )); + const hostsUsingCredential = await db + .select() + .from(sshData) + .where( + and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)), + ); - if (credentialToDelete.length === 0) { - return res.status(404).json({error: 'Credential not found'}); - } - - const hostsUsingCredential = await db - .select() - .from(sshData) - .where(and( - eq(sshData.credentialId, parseInt(id)), - eq(sshData.userId, userId) - )); - - if (hostsUsingCredential.length > 0) { - await db - .update(sshData) - .set({ - credentialId: null, - password: null, - key: null, - keyPassword: null, - authType: 'password' - }) - .where(and( - eq(sshData.credentialId, parseInt(id)), - eq(sshData.userId, userId) - )); - } - - await db - .delete(sshCredentialUsage) - .where(and( - eq(sshCredentialUsage.credentialId, parseInt(id)), - eq(sshCredentialUsage.userId, userId) - )); - - await db - .delete(sshCredentials) - .where(and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId) - )); - - const credential = credentialToDelete[0]; - authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { - operation: 'credential_delete_success', - userId, - credentialId: parseInt(id), - name: credential.name, - authType: credential.authType, - username: credential.username - }); - - res.json({message: 'Credential deleted successfully'}); - } catch (err) { - authLogger.error('Failed to delete credential', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to delete credential' - }); + if (hostsUsingCredential.length > 0) { + await db + .update(sshData) + .set({ + credentialId: null, + password: null, + key: null, + keyPassword: null, + authType: "password", + }) + .where( + and( + eq(sshData.credentialId, parseInt(id)), + eq(sshData.userId, userId), + ), + ); } + + await db + .delete(sshCredentialUsage) + .where( + and( + eq(sshCredentialUsage.credentialId, parseInt(id)), + eq(sshCredentialUsage.userId, userId), + ), + ); + + await db + .delete(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + ); + + const credential = credentialToDelete[0]; + authLogger.success( + `SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, + { + operation: "credential_delete_success", + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username, + }, + ); + + res.json({ message: "Credential deleted successfully" }); + } catch (err) { + authLogger.error("Failed to delete credential", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete credential", + }); + } }); // Apply a credential to an SSH host (for quick application) // POST /credentials/:id/apply-to-host/:hostId -router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => { +router.post( + "/:id/apply-to-host/:hostId", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {id: credentialId, hostId} = req.params; + const { id: credentialId, hostId } = req.params; if (!isNonEmptyString(userId) || !credentialId || !hostId) { - authLogger.warn('Invalid request for credential application'); - return res.status(400).json({error: 'Invalid request'}); + authLogger.warn("Invalid request for credential application"); + return res.status(400).json({ error: "Invalid request" }); } try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, parseInt(credentialId)), - eq(sshCredentials.userId, userId) - )); + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(credentialId)), + eq(sshCredentials.userId, userId), + ), + ); - if (credentials.length === 0) { - return res.status(404).json({error: 'Credential not found'}); - } + if (credentials.length === 0) { + return res.status(404).json({ error: "Credential not found" }); + } - const credential = credentials[0]; + const credential = credentials[0]; - await db - .update(sshData) - .set({ - credentialId: parseInt(credentialId), - username: credential.username, - authType: credential.authType, - password: null, - key: null, - keyPassword: null, - keyType: null, - updatedAt: new Date().toISOString() - }) - .where(and( - eq(sshData.id, parseInt(hostId)), - eq(sshData.userId, userId) - )); + await db + .update(sshData) + .set({ + credentialId: parseInt(credentialId), + username: credential.username, + authType: credential.authType, + password: null, + key: null, + keyPassword: null, + keyType: null, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)), + ); - await db.insert(sshCredentialUsage).values({ - credentialId: parseInt(credentialId), - hostId: parseInt(hostId), - userId, - }); + await db.insert(sshCredentialUsage).values({ + credentialId: parseInt(credentialId), + hostId: parseInt(hostId), + userId, + }); - await db - .update(sshCredentials) - .set({ - usageCount: sql`${sshCredentials.usageCount} + await db + .update(sshCredentials) + .set({ + usageCount: sql`${sshCredentials.usageCount} + 1`, - lastUsed: new Date().toISOString(), - updatedAt: new Date().toISOString() - }) - .where(eq(sshCredentials.id, parseInt(credentialId))); - res.json({message: 'Credential applied to host successfully'}); + lastUsed: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where(eq(sshCredentials.id, parseInt(credentialId))); + res.json({ message: "Credential applied to host successfully" }); } catch (err) { - authLogger.error('Failed to apply credential to host', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to apply credential to host' - }); + authLogger.error("Failed to apply credential to host", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to apply credential to host", + }); } -}); + }, +); // Get hosts using a specific credential // GET /credentials/:id/hosts -router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => { +router.get( + "/:id/hosts", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {id: credentialId} = req.params; + const { id: credentialId } = req.params; if (!isNonEmptyString(userId) || !credentialId) { - authLogger.warn('Invalid request for credential hosts fetch'); - return res.status(400).json({error: 'Invalid request'}); + authLogger.warn("Invalid request for credential hosts fetch"); + return res.status(400).json({ error: "Invalid request" }); } try { - const hosts = await db - .select() - .from(sshData) - .where(and( - eq(sshData.credentialId, parseInt(credentialId)), - eq(sshData.userId, userId) - )); + const hosts = await db + .select() + .from(sshData) + .where( + and( + eq(sshData.credentialId, parseInt(credentialId)), + eq(sshData.userId, userId), + ), + ); - res.json(hosts.map(host => formatSSHHostOutput(host))); + res.json(hosts.map((host) => formatSSHHostOutput(host))); } catch (err) { - authLogger.error('Failed to fetch hosts using credential', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential' - }); + authLogger.error("Failed to fetch hosts using credential", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to fetch hosts using credential", + }); } -}); + }, +); function formatCredentialOutput(credential: any): any { - return { - id: credential.id, - name: credential.name, - description: credential.description, - folder: credential.folder, - tags: typeof credential.tags === 'string' - ? (credential.tags ? credential.tags.split(',').filter(Boolean) : []) - : [], - authType: credential.authType, - username: credential.username, - keyType: credential.keyType, - usageCount: credential.usageCount || 0, - lastUsed: credential.lastUsed, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - }; + return { + id: credential.id, + name: credential.name, + description: credential.description, + folder: credential.folder, + tags: + typeof credential.tags === "string" + ? credential.tags + ? credential.tags.split(",").filter(Boolean) + : [] + : [], + authType: credential.authType, + username: credential.username, + keyType: credential.keyType, + usageCount: credential.usageCount || 0, + lastUsed: credential.lastUsed, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + }; } function formatSSHHostOutput(host: any): any { - return { - id: host.id, - userId: host.userId, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: typeof host.tags === 'string' - ? (host.tags ? host.tags.split(',').filter(Boolean) : []) - : [], - pin: !!host.pin, - authType: host.authType, - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], - enableFileManager: !!host.enableFileManager, - defaultPath: host.defaultPath, - createdAt: host.createdAt, - updatedAt: host.updatedAt, - }; + return { + id: host.id, + userId: host.userId, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: + typeof host.tags === "string" + ? host.tags + ? host.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!host.pin, + authType: host.authType, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + tunnelConnections: host.tunnelConnections + ? JSON.parse(host.tunnelConnections) + : [], + enableFileManager: !!host.enableFileManager, + defaultPath: host.defaultPath, + createdAt: host.createdAt, + updatedAt: host.updatedAt, + }; } // Rename a credential folder // PUT /credentials/folders/rename -router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { +router.put( + "/folders/rename", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {oldName, newName} = req.body; + const { oldName, newName } = req.body; if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { - return res.status(400).json({error: 'Both oldName and newName are required'}); + return res + .status(400) + .json({ error: "Both oldName and newName are required" }); } if (oldName === newName) { - return res.status(400).json({error: 'Old name and new name cannot be the same'}); + return res + .status(400) + .json({ error: "Old name and new name cannot be the same" }); } try { - await db.update(sshCredentials) - .set({folder: newName}) - .where(and( - eq(sshCredentials.userId, userId), - eq(sshCredentials.folder, oldName) - )); + await db + .update(sshCredentials) + .set({ folder: newName }) + .where( + and( + eq(sshCredentials.userId, userId), + eq(sshCredentials.folder, oldName), + ), + ); - res.json({success: true, message: 'Folder renamed successfully'}); + res.json({ success: true, message: "Folder renamed successfully" }); } catch (error) { - authLogger.error('Error renaming credential folder:', error); - res.status(500).json({error: 'Failed to rename folder'}); + authLogger.error("Error renaming credential folder:", error); + res.status(500).json({ error: "Failed to rename folder" }); } -}); + }, +); -export default router; \ No newline at end of file +export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 09ff0078..8cea7525 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -1,984 +1,1204 @@ -import express from 'express'; -import {db} from '../db/index.js'; -import {sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts} from '../db/schema.js'; -import {eq, and, desc} from 'drizzle-orm'; -import type {Request, Response, NextFunction} from 'express'; -import jwt from 'jsonwebtoken'; -import multer from 'multer'; -import {sshLogger} from '../../utils/logger.js'; +import express from "express"; +import { db } from "../db/index.js"; +import { + sshData, + sshCredentials, + fileManagerRecent, + fileManagerPinned, + fileManagerShortcuts, +} from "../db/schema.js"; +import { eq, and, desc } from "drizzle-orm"; +import type { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import multer from "multer"; +import { sshLogger } from "../../utils/logger.js"; const router = express.Router(); -const upload = multer({storage: multer.memoryStorage()}); +const upload = multer({ storage: multer.memoryStorage() }); interface JWTPayload { - userId: string; + userId: string; } function isNonEmptyString(value: any): value is string { - return typeof value === 'string' && value.trim().length > 0; + return typeof value === "string" && value.trim().length > 0; } function isValidPort(port: any): port is number { - return typeof port === 'number' && port > 0 && port <= 65535; + return typeof port === "number" && port > 0 && port <= 65535; } function authenticateJWT(req: Request, res: Response, next: NextFunction) { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - sshLogger.warn('Missing or invalid Authorization header'); - return res.status(401).json({error: 'Missing or invalid Authorization header'}); - } - const token = authHeader.split(' ')[1]; - const jwtSecret = process.env.JWT_SECRET || 'secret'; - try { - const payload = jwt.verify(token, jwtSecret) as JWTPayload; - (req as any).userId = payload.userId; - next(); - } catch (err) { - sshLogger.warn('Invalid or expired token'); - return res.status(401).json({error: 'Invalid or expired token'}); - } + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + sshLogger.warn("Missing or invalid Authorization header"); + return res + .status(401) + .json({ error: "Missing or invalid Authorization header" }); + } + const token = authHeader.split(" ")[1]; + const jwtSecret = process.env.JWT_SECRET || "secret"; + try { + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + (req as any).userId = payload.userId; + next(); + } catch (err) { + sshLogger.warn("Invalid or expired token"); + return res.status(401).json({ error: "Invalid or expired token" }); + } } function isLocalhost(req: Request) { - const ip = req.ip || req.connection?.remoteAddress; - return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + const ip = req.ip || req.connection?.remoteAddress; + return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; } // Internal-only endpoint for autostart (no JWT) -router.get('/db/host/internal', async (req: Request, res: Response) => { - if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') { - sshLogger.warn('Unauthorized attempt to access internal SSH host endpoint'); - return res.status(403).json({error: 'Forbidden'}); - } - try { - const data = await db.select().from(sshData); - const result = data.map((row: any) => { - return { - ...row, - tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], - enableFileManager: !!row.enableFileManager, - }; - }); - res.json(result); - } catch (err) { - sshLogger.error('Failed to fetch SSH data (internal)', err); - res.status(500).json({error: 'Failed to fetch SSH data'}); - } +router.get("/db/host/internal", async (req: Request, res: Response) => { + if (!isLocalhost(req) && req.headers["x-internal-request"] !== "1") { + sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint"); + return res.status(403).json({ error: "Forbidden" }); + } + try { + const data = await db.select().from(sshData); + const result = data.map((row: any) => { + return { + ...row, + tags: + typeof row.tags === "string" + ? row.tags + ? row.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections + ? JSON.parse(row.tunnelConnections) + : [], + enableFileManager: !!row.enableFileManager, + }; + }); + res.json(result); + } catch (err) { + sshLogger.error("Failed to fetch SSH data (internal)", err); + res.status(500).json({ error: "Failed to fetch SSH data" }); + } }); // Route: Create SSH data (requires JWT) // POST /ssh/host -router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { +router.post( + "/db/host", + authenticateJWT, + upload.single("key"), + async (req: Request, res: Response) => { const userId = (req as any).userId; let hostData: any; - if (req.headers['content-type']?.includes('multipart/form-data')) { - if (req.body.data) { - try { - hostData = JSON.parse(req.body.data); - } catch (err) { - sshLogger.warn('Invalid JSON data in multipart request', { - operation: 'host_create', - userId, - error: err - }); - return res.status(400).json({error: 'Invalid JSON data'}); - } - } else { - sshLogger.warn('Missing data field in multipart request', {operation: 'host_create', userId}); - return res.status(400).json({error: 'Missing data field'}); + if (req.headers["content-type"]?.includes("multipart/form-data")) { + if (req.body.data) { + try { + hostData = JSON.parse(req.body.data); + } catch (err) { + sshLogger.warn("Invalid JSON data in multipart request", { + operation: "host_create", + userId, + error: err, + }); + return res.status(400).json({ error: "Invalid JSON data" }); } + } else { + sshLogger.warn("Missing data field in multipart request", { + operation: "host_create", + userId, + }); + return res.status(400).json({ error: "Missing data field" }); + } - if (req.file) { - hostData.key = req.file.buffer.toString('utf8'); - } + if (req.file) { + hostData.key = req.file.buffer.toString("utf8"); + } } else { - hostData = req.body; + hostData = req.body; } const { - name, - folder, - tags, - ip, - port, - username, - password, - authMethod, - authType, - credentialId, - key, - keyPassword, - keyType, - pin, - enableTerminal, - enableTunnel, - enableFileManager, - defaultPath, - tunnelConnections + name, + folder, + tags, + ip, + port, + username, + password, + authMethod, + authType, + credentialId, + key, + keyPassword, + keyType, + pin, + enableTerminal, + enableTunnel, + enableFileManager, + defaultPath, + tunnelConnections, } = hostData; - if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { - sshLogger.warn('Invalid SSH data input validation failed', { - operation: 'host_create', - userId, - hasIp: !!ip, - port, - isValidPort: isValidPort(port) - }); - return res.status(400).json({error: 'Invalid SSH data'}); + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(ip) || + !isValidPort(port) + ) { + sshLogger.warn("Invalid SSH data input validation failed", { + operation: "host_create", + userId, + hasIp: !!ip, + port, + isValidPort: isValidPort(port), + }); + return res.status(400).json({ error: "Invalid SSH data" }); } const effectiveAuthType = authType || authMethod; const sshDataObj: any = { - userId: userId, - name, - folder: folder || null, - tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), - ip, - port, - username, - authType: effectiveAuthType, - credentialId: credentialId || null, - pin: pin ? 1 : 0, - enableTerminal: enableTerminal ? 1 : 0, - enableTunnel: enableTunnel ? 1 : 0, - tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, - enableFileManager: enableFileManager ? 1 : 0, - defaultPath: defaultPath || null, + userId: userId, + name, + folder: folder || null, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + ip, + port, + username, + authType: effectiveAuthType, + credentialId: credentialId || null, + pin: pin ? 1 : 0, + enableTerminal: enableTerminal ? 1 : 0, + enableTunnel: enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(tunnelConnections) + ? JSON.stringify(tunnelConnections) + : null, + enableFileManager: enableFileManager ? 1 : 0, + defaultPath: defaultPath || null, }; - if (effectiveAuthType === 'password') { - sshDataObj.password = password || null; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (effectiveAuthType === 'key') { - sshDataObj.key = key || null; - sshDataObj.keyPassword = keyPassword || null; - sshDataObj.keyType = keyType; - sshDataObj.password = null; + if (effectiveAuthType === "password") { + sshDataObj.password = password || null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (effectiveAuthType === "key") { + sshDataObj.key = key || null; + sshDataObj.keyPassword = keyPassword || null; + sshDataObj.keyType = keyType; + sshDataObj.password = null; } try { - const result = await db.insert(sshData).values(sshDataObj).returning(); + const result = await db.insert(sshData).values(sshDataObj).returning(); - if (result.length === 0) { - sshLogger.warn('No host returned after creation', {operation: 'host_create', userId, name, ip, port}); - return res.status(500).json({error: 'Failed to create host'}); - } - - const createdHost = result[0]; - const baseHost = { - ...createdHost, - tags: typeof createdHost.tags === 'string' ? (createdHost.tags ? createdHost.tags.split(',').filter(Boolean) : []) : [], - pin: !!createdHost.pin, - enableTerminal: !!createdHost.enableTerminal, - enableTunnel: !!createdHost.enableTunnel, - tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections) : [], - enableFileManager: !!createdHost.enableFileManager, - }; - - const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; - - sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, { - operation: 'host_create_success', - userId, - hostId: createdHost.id, - name, - ip, - port, - authType: effectiveAuthType + if (result.length === 0) { + sshLogger.warn("No host returned after creation", { + operation: "host_create", + userId, + name, + ip, + port, }); + return res.status(500).json({ error: "Failed to create host" }); + } - res.json(resolvedHost); + const createdHost = result[0]; + const baseHost = { + ...createdHost, + tags: + typeof createdHost.tags === "string" + ? createdHost.tags + ? createdHost.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!createdHost.pin, + enableTerminal: !!createdHost.enableTerminal, + enableTunnel: !!createdHost.enableTunnel, + tunnelConnections: createdHost.tunnelConnections + ? JSON.parse(createdHost.tunnelConnections) + : [], + enableFileManager: !!createdHost.enableFileManager, + }; + + const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; + + sshLogger.success( + `SSH host created: ${name} (${ip}:${port}) by user ${userId}`, + { + operation: "host_create_success", + userId, + hostId: createdHost.id, + name, + ip, + port, + authType: effectiveAuthType, + }, + ); + + res.json(resolvedHost); } catch (err) { - sshLogger.error('Failed to save SSH host to database', err, { - operation: 'host_create', - userId, - name, - ip, - port, - authType: effectiveAuthType - }); - res.status(500).json({error: 'Failed to save SSH data'}); + sshLogger.error("Failed to save SSH host to database", err, { + operation: "host_create", + userId, + name, + ip, + port, + authType: effectiveAuthType, + }); + res.status(500).json({ error: "Failed to save SSH data" }); } -}); + }, +); // Route: Update SSH data (requires JWT) // PUT /ssh/host/:id -router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { +router.put( + "/db/host/:id", + authenticateJWT, + upload.single("key"), + async (req: Request, res: Response) => { const hostId = req.params.id; const userId = (req as any).userId; let hostData: any; - if (req.headers['content-type']?.includes('multipart/form-data')) { - if (req.body.data) { - try { - hostData = JSON.parse(req.body.data); - } catch (err) { - sshLogger.warn('Invalid JSON data in multipart request', { - operation: 'host_update', - hostId: parseInt(hostId), - userId, - error: err - }); - return res.status(400).json({error: 'Invalid JSON data'}); - } - } else { - sshLogger.warn('Missing data field in multipart request', { - operation: 'host_update', - hostId: parseInt(hostId), - userId - }); - return res.status(400).json({error: 'Missing data field'}); + if (req.headers["content-type"]?.includes("multipart/form-data")) { + if (req.body.data) { + try { + hostData = JSON.parse(req.body.data); + } catch (err) { + sshLogger.warn("Invalid JSON data in multipart request", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + error: err, + }); + return res.status(400).json({ error: "Invalid JSON data" }); } + } else { + sshLogger.warn("Missing data field in multipart request", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + }); + return res.status(400).json({ error: "Missing data field" }); + } - if (req.file) { - hostData.key = req.file.buffer.toString('utf8'); - } + if (req.file) { + hostData.key = req.file.buffer.toString("utf8"); + } } else { - hostData = req.body; + hostData = req.body; } const { - name, - folder, - tags, - ip, - port, - username, - password, - authMethod, - authType, - credentialId, - key, - keyPassword, - keyType, - pin, - enableTerminal, - enableTunnel, - enableFileManager, - defaultPath, - tunnelConnections + name, + folder, + tags, + ip, + port, + username, + password, + authMethod, + authType, + credentialId, + key, + keyPassword, + keyType, + pin, + enableTerminal, + enableTunnel, + enableFileManager, + defaultPath, + tunnelConnections, } = hostData; - if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !hostId) { - sshLogger.warn('Invalid SSH data input validation failed for update', { - operation: 'host_update', - hostId: parseInt(hostId), - userId, - hasIp: !!ip, - port, - isValidPort: isValidPort(port) - }); - return res.status(400).json({error: 'Invalid SSH data'}); + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(ip) || + !isValidPort(port) || + !hostId + ) { + sshLogger.warn("Invalid SSH data input validation failed for update", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + hasIp: !!ip, + port, + isValidPort: isValidPort(port), + }); + return res.status(400).json({ error: "Invalid SSH data" }); } const effectiveAuthType = authType || authMethod; const sshDataObj: any = { - name, - folder, - tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), - ip, - port, - username, - authType: effectiveAuthType, - credentialId: credentialId || null, - pin: pin ? 1 : 0, - enableTerminal: enableTerminal ? 1 : 0, - enableTunnel: enableTunnel ? 1 : 0, - tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, - enableFileManager: enableFileManager ? 1 : 0, - defaultPath: defaultPath || null, + name, + folder, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + ip, + port, + username, + authType: effectiveAuthType, + credentialId: credentialId || null, + pin: pin ? 1 : 0, + enableTerminal: enableTerminal ? 1 : 0, + enableTunnel: enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(tunnelConnections) + ? JSON.stringify(tunnelConnections) + : null, + enableFileManager: enableFileManager ? 1 : 0, + defaultPath: defaultPath || null, }; - if (effectiveAuthType === 'password') { - if (password) { - sshDataObj.password = password; - } - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (effectiveAuthType === 'key') { - if (key) { - sshDataObj.key = key; - } - if (keyPassword !== undefined) { - sshDataObj.keyPassword = keyPassword || null; - } - if (keyType) { - sshDataObj.keyType = keyType; - } - sshDataObj.password = null; + if (effectiveAuthType === "password") { + if (password) { + sshDataObj.password = password; + } + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (effectiveAuthType === "key") { + if (key) { + sshDataObj.key = key; + } + if (keyPassword !== undefined) { + sshDataObj.keyPassword = keyPassword || null; + } + if (keyType) { + sshDataObj.keyType = keyType; + } + sshDataObj.password = null; } try { - await db.update(sshData) - .set(sshDataObj) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + await db + .update(sshData) + .set(sshDataObj) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - const updatedHosts = await db - .select() - .from(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + const updatedHosts = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - if (updatedHosts.length === 0) { - sshLogger.warn('Updated host not found after update', { - operation: 'host_update', - hostId: parseInt(hostId), - userId - }); - return res.status(404).json({error: 'Host not found after update'}); - } - - const updatedHost = updatedHosts[0]; - const baseHost = { - ...updatedHost, - tags: typeof updatedHost.tags === 'string' ? (updatedHost.tags ? updatedHost.tags.split(',').filter(Boolean) : []) : [], - pin: !!updatedHost.pin, - enableTerminal: !!updatedHost.enableTerminal, - enableTunnel: !!updatedHost.enableTunnel, - tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections) : [], - enableFileManager: !!updatedHost.enableFileManager, - }; - - const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; - - sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, { - operation: 'host_update_success', - userId, - hostId: parseInt(hostId), - name, - ip, - port, - authType: effectiveAuthType + if (updatedHosts.length === 0) { + sshLogger.warn("Updated host not found after update", { + operation: "host_update", + hostId: parseInt(hostId), + userId, }); + return res.status(404).json({ error: "Host not found after update" }); + } - res.json(resolvedHost); + const updatedHost = updatedHosts[0]; + const baseHost = { + ...updatedHost, + tags: + typeof updatedHost.tags === "string" + ? updatedHost.tags + ? updatedHost.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!updatedHost.pin, + enableTerminal: !!updatedHost.enableTerminal, + enableTunnel: !!updatedHost.enableTunnel, + tunnelConnections: updatedHost.tunnelConnections + ? JSON.parse(updatedHost.tunnelConnections) + : [], + enableFileManager: !!updatedHost.enableFileManager, + }; + + const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; + + sshLogger.success( + `SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, + { + operation: "host_update_success", + userId, + hostId: parseInt(hostId), + name, + ip, + port, + authType: effectiveAuthType, + }, + ); + + res.json(resolvedHost); } catch (err) { - sshLogger.error('Failed to update SSH host in database', err, { - operation: 'host_update', - hostId: parseInt(hostId), - userId, - name, - ip, - port, - authType: effectiveAuthType - }); - res.status(500).json({error: 'Failed to update SSH data'}); + sshLogger.error("Failed to update SSH host in database", err, { + operation: "host_update", + hostId: parseInt(hostId), + userId, + name, + ip, + port, + authType: effectiveAuthType, + }); + res.status(500).json({ error: "Failed to update SSH data" }); } -}); + }, +); // Route: Get SSH data for the authenticated user (requires JWT) // GET /ssh/host -router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - sshLogger.warn('Invalid userId for SSH data fetch', {operation: 'host_fetch', userId}); - return res.status(400).json({error: 'Invalid userId'}); - } - try { - const data = await db - .select() - .from(sshData) - .where(eq(sshData.userId, userId)); +router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + if (!isNonEmptyString(userId)) { + sshLogger.warn("Invalid userId for SSH data fetch", { + operation: "host_fetch", + userId, + }); + return res.status(400).json({ error: "Invalid userId" }); + } + try { + const data = await db + .select() + .from(sshData) + .where(eq(sshData.userId, userId)); - const result = await Promise.all(data.map(async (row: any) => { - const baseHost = { - ...row, - tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], - enableFileManager: !!row.enableFileManager, - }; + const result = await Promise.all( + data.map(async (row: any) => { + const baseHost = { + ...row, + tags: + typeof row.tags === "string" + ? row.tags + ? row.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections + ? JSON.parse(row.tunnelConnections) + : [], + enableFileManager: !!row.enableFileManager, + }; - return await resolveHostCredentials(baseHost) || baseHost; - })); + return (await resolveHostCredentials(baseHost)) || baseHost; + }), + ); - res.json(result); - } catch (err) { - sshLogger.error('Failed to fetch SSH hosts from database', err, {operation: 'host_fetch', userId}); - res.status(500).json({error: 'Failed to fetch SSH data'}); - } + res.json(result); + } catch (err) { + sshLogger.error("Failed to fetch SSH hosts from database", err, { + operation: "host_fetch", + userId, + }); + res.status(500).json({ error: "Failed to fetch SSH data" }); + } }); // Route: Get SSH host by ID (requires JWT) // GET /ssh/host/:id -router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { +router.get( + "/db/host/:id", + authenticateJWT, + async (req: Request, res: Response) => { const hostId = req.params.id; const userId = (req as any).userId; if (!isNonEmptyString(userId) || !hostId) { - sshLogger.warn('Invalid userId or hostId for SSH host fetch by ID', { - operation: 'host_fetch_by_id', - hostId: parseInt(hostId), - userId - }); - return res.status(400).json({error: 'Invalid userId or hostId'}); + sshLogger.warn("Invalid userId or hostId for SSH host fetch by ID", { + operation: "host_fetch_by_id", + hostId: parseInt(hostId), + userId, + }); + return res.status(400).json({ error: "Invalid userId or hostId" }); } try { - const data = await db - .select() - .from(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + const data = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - if (data.length === 0) { - sshLogger.warn('SSH host not found', {operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId}); - return res.status(404).json({error: 'SSH host not found'}); - } - - const host = data[0]; - const result = { - ...host, - tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [], - pin: !!host.pin, - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], - enableFileManager: !!host.enableFileManager, - }; - - res.json(await resolveHostCredentials(result) || result); - } catch (err) { - sshLogger.error('Failed to fetch SSH host by ID from database', err, { - operation: 'host_fetch_by_id', - hostId: parseInt(hostId), - userId + if (data.length === 0) { + sshLogger.warn("SSH host not found", { + operation: "host_fetch_by_id", + hostId: parseInt(hostId), + userId, }); - res.status(500).json({error: 'Failed to fetch SSH host'}); + return res.status(404).json({ error: "SSH host not found" }); + } + + const host = data[0]; + const result = { + ...host, + tags: + typeof host.tags === "string" + ? host.tags + ? host.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!host.pin, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + tunnelConnections: host.tunnelConnections + ? JSON.parse(host.tunnelConnections) + : [], + enableFileManager: !!host.enableFileManager, + }; + + res.json((await resolveHostCredentials(result)) || result); + } catch (err) { + sshLogger.error("Failed to fetch SSH host by ID from database", err, { + operation: "host_fetch_by_id", + hostId: parseInt(hostId), + userId, + }); + res.status(500).json({ error: "Failed to fetch SSH host" }); } -}); + }, +); // Route: Delete SSH host by id (requires JWT) // DELETE /ssh/host/:id -router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { +router.delete( + "/db/host/:id", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; const hostId = req.params.id; if (!isNonEmptyString(userId) || !hostId) { - sshLogger.warn('Invalid userId or hostId for SSH host delete', { - operation: 'host_delete', - hostId: parseInt(hostId), - userId - }); - return res.status(400).json({error: 'Invalid userId or id'}); + sshLogger.warn("Invalid userId or hostId for SSH host delete", { + operation: "host_delete", + hostId: parseInt(hostId), + userId, + }); + return res.status(400).json({ error: "Invalid userId or id" }); } try { - const hostToDelete = await db - .select() - .from(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + const hostToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - if (hostToDelete.length === 0) { - sshLogger.warn('SSH host not found for deletion', { - operation: 'host_delete', - hostId: parseInt(hostId), - userId - }); - return res.status(404).json({error: 'SSH host not found'}); - } - - const result = await db.delete(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - - const host = hostToDelete[0]; - sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, { - operation: 'host_delete_success', - userId, - hostId: parseInt(hostId), - name: host.name, - ip: host.ip, - port: host.port + if (hostToDelete.length === 0) { + sshLogger.warn("SSH host not found for deletion", { + operation: "host_delete", + hostId: parseInt(hostId), + userId, }); + return res.status(404).json({ error: "SSH host not found" }); + } - res.json({message: 'SSH host deleted'}); + const result = await db + .delete(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + + const host = hostToDelete[0]; + sshLogger.success( + `SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, + { + operation: "host_delete_success", + userId, + hostId: parseInt(hostId), + name: host.name, + ip: host.ip, + port: host.port, + }, + ); + + res.json({ message: "SSH host deleted" }); } catch (err) { - sshLogger.error('Failed to delete SSH host from database', err, { - operation: 'host_delete', - hostId: parseInt(hostId), - userId - }); - res.status(500).json({error: 'Failed to delete SSH host'}); + sshLogger.error("Failed to delete SSH host from database", err, { + operation: "host_delete", + hostId: parseInt(hostId), + userId, + }); + res.status(500).json({ error: "Failed to delete SSH host" }); } -}); + }, +); // Route: Get recent files (requires JWT) // GET /ssh/file_manager/recent -router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { +router.get( + "/file_manager/recent", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; + const hostId = req.query.hostId + ? parseInt(req.query.hostId as string) + : null; if (!isNonEmptyString(userId)) { - sshLogger.warn('Invalid userId for recent files fetch'); - return res.status(400).json({error: 'Invalid userId'}); + sshLogger.warn("Invalid userId for recent files fetch"); + return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { - sshLogger.warn('Host ID is required for recent files fetch'); - return res.status(400).json({error: 'Host ID is required'}); + sshLogger.warn("Host ID is required for recent files fetch"); + return res.status(400).json({ error: "Host ID is required" }); } try { - const recentFiles = await db - .select() - .from(fileManagerRecent) - .where(and(eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, hostId))) - .orderBy(desc(fileManagerRecent.lastOpened)) - .limit(20); + const recentFiles = await db + .select() + .from(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerRecent.lastOpened)) + .limit(20); - res.json(recentFiles); + res.json(recentFiles); } catch (err) { - sshLogger.error('Failed to fetch recent files', err); - res.status(500).json({error: 'Failed to fetch recent files'}); + sshLogger.error("Failed to fetch recent files", err); + res.status(500).json({ error: "Failed to fetch recent files" }); } -}); + }, +); // Route: Add recent file (requires JWT) // POST /ssh/file_manager/recent -router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { +router.post( + "/file_manager/recent", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for recent file addition'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for recent file addition"); + return res.status(400).json({ error: "Invalid data" }); } try { - const existing = await db - .select() - .from(fileManagerRecent) - .where(and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path) - )); + const existing = await db + .select() + .from(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, hostId), + eq(fileManagerRecent.path, path), + ), + ); - if (existing.length > 0) { - await db - .update(fileManagerRecent) - .set({lastOpened: new Date().toISOString()}) - .where(eq(fileManagerRecent.id, existing[0].id)); - } else { - await db.insert(fileManagerRecent).values({ - userId, - hostId, - path, - name: name || path.split('/').pop() || 'Unknown', - lastOpened: new Date().toISOString() - }); - } + if (existing.length > 0) { + await db + .update(fileManagerRecent) + .set({ lastOpened: new Date().toISOString() }) + .where(eq(fileManagerRecent.id, existing[0].id)); + } else { + await db.insert(fileManagerRecent).values({ + userId, + hostId, + path, + name: name || path.split("/").pop() || "Unknown", + lastOpened: new Date().toISOString(), + }); + } - res.json({message: 'Recent file added'}); + res.json({ message: "Recent file added" }); } catch (err) { - sshLogger.error('Failed to add recent file', err); - res.status(500).json({error: 'Failed to add recent file'}); + sshLogger.error("Failed to add recent file", err); + res.status(500).json({ error: "Failed to add recent file" }); } -}); + }, +); // Route: Remove recent file (requires JWT) // DELETE /ssh/file_manager/recent -router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { +router.delete( + "/file_manager/recent", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for recent file deletion'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for recent file deletion"); + return res.status(400).json({ error: "Invalid data" }); } try { - await db - .delete(fileManagerRecent) - .where(and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path) - )); + await db + .delete(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, hostId), + eq(fileManagerRecent.path, path), + ), + ); - res.json({message: 'Recent file removed'}); + res.json({ message: "Recent file removed" }); } catch (err) { - sshLogger.error('Failed to remove recent file', err); - res.status(500).json({error: 'Failed to remove recent file'}); + sshLogger.error("Failed to remove recent file", err); + res.status(500).json({ error: "Failed to remove recent file" }); } -}); + }, +); // Route: Get pinned files (requires JWT) // GET /ssh/file_manager/pinned -router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { +router.get( + "/file_manager/pinned", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; + const hostId = req.query.hostId + ? parseInt(req.query.hostId as string) + : null; if (!isNonEmptyString(userId)) { - sshLogger.warn('Invalid userId for pinned files fetch'); - return res.status(400).json({error: 'Invalid userId'}); + sshLogger.warn("Invalid userId for pinned files fetch"); + return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { - sshLogger.warn('Host ID is required for pinned files fetch'); - return res.status(400).json({error: 'Host ID is required'}); + sshLogger.warn("Host ID is required for pinned files fetch"); + return res.status(400).json({ error: "Host ID is required" }); } try { - const pinnedFiles = await db - .select() - .from(fileManagerPinned) - .where(and(eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, hostId))) - .orderBy(desc(fileManagerPinned.pinnedAt)); + const pinnedFiles = await db + .select() + .from(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerPinned.pinnedAt)); - res.json(pinnedFiles); + res.json(pinnedFiles); } catch (err) { - sshLogger.error('Failed to fetch pinned files', err); - res.status(500).json({error: 'Failed to fetch pinned files'}); + sshLogger.error("Failed to fetch pinned files", err); + res.status(500).json({ error: "Failed to fetch pinned files" }); } -}); + }, +); // Route: Add pinned file (requires JWT) // POST /ssh/file_manager/pinned -router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { +router.post( + "/file_manager/pinned", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for pinned file addition'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for pinned file addition"); + return res.status(400).json({ error: "Invalid data" }); } try { - const existing = await db - .select() - .from(fileManagerPinned) - .where(and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path) - )); + const existing = await db + .select() + .from(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + eq(fileManagerPinned.path, path), + ), + ); - if (existing.length > 0) { - return res.status(409).json({error: 'File already pinned'}); - } + if (existing.length > 0) { + return res.status(409).json({ error: "File already pinned" }); + } - await db.insert(fileManagerPinned).values({ - userId, - hostId, - path, - name: name || path.split('/').pop() || 'Unknown', - pinnedAt: new Date().toISOString() - }); + await db.insert(fileManagerPinned).values({ + userId, + hostId, + path, + name: name || path.split("/").pop() || "Unknown", + pinnedAt: new Date().toISOString(), + }); - res.json({message: 'File pinned'}); + res.json({ message: "File pinned" }); } catch (err) { - sshLogger.error('Failed to pin file', err); - res.status(500).json({error: 'Failed to pin file'}); + sshLogger.error("Failed to pin file", err); + res.status(500).json({ error: "Failed to pin file" }); } -}); + }, +); // Route: Remove pinned file (requires JWT) // DELETE /ssh/file_manager/pinned -router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { +router.delete( + "/file_manager/pinned", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for pinned file deletion'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for pinned file deletion"); + return res.status(400).json({ error: "Invalid data" }); } try { - await db - .delete(fileManagerPinned) - .where(and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path) - )); + await db + .delete(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + eq(fileManagerPinned.path, path), + ), + ); - res.json({message: 'Pinned file removed'}); + res.json({ message: "Pinned file removed" }); } catch (err) { - sshLogger.error('Failed to remove pinned file', err); - res.status(500).json({error: 'Failed to remove pinned file'}); + sshLogger.error("Failed to remove pinned file", err); + res.status(500).json({ error: "Failed to remove pinned file" }); } -}); + }, +); // Route: Get shortcuts (requires JWT) // GET /ssh/file_manager/shortcuts -router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { +router.get( + "/file_manager/shortcuts", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; + const hostId = req.query.hostId + ? parseInt(req.query.hostId as string) + : null; if (!isNonEmptyString(userId)) { - sshLogger.warn('Invalid userId for shortcuts fetch'); - return res.status(400).json({error: 'Invalid userId'}); + sshLogger.warn("Invalid userId for shortcuts fetch"); + return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { - sshLogger.warn('Host ID is required for shortcuts fetch'); - return res.status(400).json({error: 'Host ID is required'}); + sshLogger.warn("Host ID is required for shortcuts fetch"); + return res.status(400).json({ error: "Host ID is required" }); } try { - const shortcuts = await db - .select() - .from(fileManagerShortcuts) - .where(and(eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, hostId))) - .orderBy(desc(fileManagerShortcuts.createdAt)); + const shortcuts = await db + .select() + .from(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerShortcuts.createdAt)); - res.json(shortcuts); + res.json(shortcuts); } catch (err) { - sshLogger.error('Failed to fetch shortcuts', err); - res.status(500).json({error: 'Failed to fetch shortcuts'}); + sshLogger.error("Failed to fetch shortcuts", err); + res.status(500).json({ error: "Failed to fetch shortcuts" }); } -}); + }, +); // Route: Add shortcut (requires JWT) // POST /ssh/file_manager/shortcuts -router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { +router.post( + "/file_manager/shortcuts", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for shortcut addition'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for shortcut addition"); + return res.status(400).json({ error: "Invalid data" }); } try { - const existing = await db - .select() - .from(fileManagerShortcuts) - .where(and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path) - )); + const existing = await db + .select() + .from(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + eq(fileManagerShortcuts.path, path), + ), + ); - if (existing.length > 0) { - return res.status(409).json({error: 'Shortcut already exists'}); - } + if (existing.length > 0) { + return res.status(409).json({ error: "Shortcut already exists" }); + } - await db.insert(fileManagerShortcuts).values({ - userId, - hostId, - path, - name: name || path.split('/').pop() || 'Unknown', - createdAt: new Date().toISOString() - }); + await db.insert(fileManagerShortcuts).values({ + userId, + hostId, + path, + name: name || path.split("/").pop() || "Unknown", + createdAt: new Date().toISOString(), + }); - res.json({message: 'Shortcut added'}); + res.json({ message: "Shortcut added" }); } catch (err) { - sshLogger.error('Failed to add shortcut', err); - res.status(500).json({error: 'Failed to add shortcut'}); + sshLogger.error("Failed to add shortcut", err); + res.status(500).json({ error: "Failed to add shortcut" }); } -}); + }, +); // Route: Remove shortcut (requires JWT) // DELETE /ssh/file_manager/shortcuts -router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { +router.delete( + "/file_manager/shortcuts", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hostId, path, name} = req.body; + const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { - sshLogger.warn('Invalid data for shortcut deletion'); - return res.status(400).json({error: 'Invalid data'}); + sshLogger.warn("Invalid data for shortcut deletion"); + return res.status(400).json({ error: "Invalid data" }); } try { - await db - .delete(fileManagerShortcuts) - .where(and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path) - )); + await db + .delete(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + eq(fileManagerShortcuts.path, path), + ), + ); - res.json({message: 'Shortcut removed'}); + res.json({ message: "Shortcut removed" }); } catch (err) { - sshLogger.error('Failed to remove shortcut', err); - res.status(500).json({error: 'Failed to remove shortcut'}); + sshLogger.error("Failed to remove shortcut", err); + res.status(500).json({ error: "Failed to remove shortcut" }); } -}); + }, +); async function resolveHostCredentials(host: any): Promise { - try { - if (host.credentialId && host.userId) { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, host.credentialId), - eq(sshCredentials.userId, host.userId) - )); + try { + if (host.credentialId && host.userId) { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId), + ), + ); - if (credentials.length > 0) { - const credential = credentials[0]; - return { - ...host, - username: credential.username, - authType: credential.authType, - password: credential.password, - key: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType - }; - } - } - return host; - } catch (error) { - sshLogger.warn(`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); - return host; + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + username: credential.username, + authType: credential.authType, + password: credential.password, + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + } } + return host; + } catch (error) { + sshLogger.warn( + `Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return host; + } } // Route: Rename folder (requires JWT) // PUT /ssh/db/folders/rename -router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { +router.put( + "/folders/rename", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {oldName, newName} = req.body; + const { oldName, newName } = req.body; if (!isNonEmptyString(userId) || !oldName || !newName) { - sshLogger.warn('Invalid data for folder rename'); - return res.status(400).json({error: 'Old name and new name are required'}); + sshLogger.warn("Invalid data for folder rename"); + return res + .status(400) + .json({ error: "Old name and new name are required" }); } if (oldName === newName) { - return res.json({message: 'Folder name unchanged'}); + return res.json({ message: "Folder name unchanged" }); } try { - const updatedHosts = await db - .update(sshData) - .set({ - folder: newName, - updatedAt: new Date().toISOString() - }) - .where(and( - eq(sshData.userId, userId), - eq(sshData.folder, oldName) - )) - .returning(); + const updatedHosts = await db + .update(sshData) + .set({ + folder: newName, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName))) + .returning(); - const updatedCredentials = await db - .update(sshCredentials) - .set({ - folder: newName, - updatedAt: new Date().toISOString() - }) - .where(and( - eq(sshCredentials.userId, userId), - eq(sshCredentials.folder, oldName) - )) - .returning(); + const updatedCredentials = await db + .update(sshCredentials) + .set({ + folder: newName, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq(sshCredentials.userId, userId), + eq(sshCredentials.folder, oldName), + ), + ) + .returning(); - res.json({ - message: 'Folder renamed successfully', - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length - }); + res.json({ + message: "Folder renamed successfully", + updatedHosts: updatedHosts.length, + updatedCredentials: updatedCredentials.length, + }); } catch (err) { - sshLogger.error('Failed to rename folder', err, {operation: 'folder_rename', userId, oldName, newName}); - res.status(500).json({error: 'Failed to rename folder'}); + sshLogger.error("Failed to rename folder", err, { + operation: "folder_rename", + userId, + oldName, + newName, + }); + res.status(500).json({ error: "Failed to rename folder" }); } -}); + }, +); // Route: Bulk import SSH hosts (requires JWT) // POST /ssh/bulk-import -router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { +router.post( + "/bulk-import", + authenticateJWT, + async (req: Request, res: Response) => { const userId = (req as any).userId; - const {hosts} = req.body; + const { hosts } = req.body; if (!Array.isArray(hosts) || hosts.length === 0) { - return res.status(400).json({error: 'Hosts array is required and must not be empty'}); + return res + .status(400) + .json({ error: "Hosts array is required and must not be empty" }); } if (hosts.length > 100) { - return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); + return res + .status(400) + .json({ error: "Maximum 100 hosts allowed per import" }); } const results = { - success: 0, - failed: 0, - errors: [] as string[] + success: 0, + failed: 0, + errors: [] as string[], }; for (let i = 0; i < hosts.length; i++) { - const hostData = hosts[i]; + const hostData = hosts[i]; - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Missing required fields (ip, port, username)`); - continue; - } - - if (!['password', 'key', 'credential'].includes(hostData.authType)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`); - continue; - } - - if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Password required for password authentication`); - continue; - } - - if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Key required for key authentication`); - continue; - } - - if (hostData.authType === 'credential' && !hostData.credentialId) { - results.failed++; - results.errors.push(`Host ${i + 1}: credentialId required for credential authentication`); - continue; - } - - const sshDataObj: any = { - userId: userId, - name: hostData.name || `${hostData.username}@${hostData.ip}`, - folder: hostData.folder || 'Default', - tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : '', - ip: hostData.ip, - port: hostData.port, - username: hostData.username, - password: hostData.authType === 'password' ? hostData.password : null, - authType: hostData.authType, - credentialId: hostData.authType === 'credential' ? hostData.credentialId : null, - key: hostData.authType === 'key' ? hostData.key : null, - keyPassword: hostData.authType === 'key' ? hostData.keyPassword : null, - keyType: hostData.authType === 'key' ? (hostData.keyType || 'auto') : null, - pin: hostData.pin || false, - enableTerminal: hostData.enableTerminal !== false, - enableTunnel: hostData.enableTunnel !== false, - enableFileManager: hostData.enableFileManager !== false, - defaultPath: hostData.defaultPath || '/', - tunnelConnections: hostData.tunnelConnections ? JSON.stringify(hostData.tunnelConnections) : '[]', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - await db.insert(sshData).values(sshDataObj); - results.success++; - - } catch (error) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`); + try { + if ( + !isNonEmptyString(hostData.ip) || + !isValidPort(hostData.port) || + !isNonEmptyString(hostData.username) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Missing required fields (ip, port, username)`, + ); + continue; } + + if (!["password", "key", "credential"].includes(hostData.authType)) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`, + ); + continue; + } + + if ( + hostData.authType === "password" && + !isNonEmptyString(hostData.password) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Password required for password authentication`, + ); + continue; + } + + if (hostData.authType === "key" && !isNonEmptyString(hostData.key)) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Key required for key authentication`, + ); + continue; + } + + if (hostData.authType === "credential" && !hostData.credentialId) { + results.failed++; + results.errors.push( + `Host ${i + 1}: credentialId required for credential authentication`, + ); + continue; + } + + const sshDataObj: any = { + userId: userId, + name: hostData.name || `${hostData.username}@${hostData.ip}`, + folder: hostData.folder || "Default", + tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + password: hostData.authType === "password" ? hostData.password : null, + authType: hostData.authType, + credentialId: + hostData.authType === "credential" ? hostData.credentialId : null, + key: hostData.authType === "key" ? hostData.key : null, + keyPassword: + hostData.authType === "key" ? hostData.keyPassword : null, + keyType: + hostData.authType === "key" ? hostData.keyType || "auto" : null, + pin: hostData.pin || false, + enableTerminal: hostData.enableTerminal !== false, + enableTunnel: hostData.enableTunnel !== false, + enableFileManager: hostData.enableFileManager !== false, + defaultPath: hostData.defaultPath || "/", + tunnelConnections: hostData.tunnelConnections + ? JSON.stringify(hostData.tunnelConnections) + : "[]", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.insert(sshData).values(sshDataObj); + results.success++; + } catch (error) { + results.failed++; + results.errors.push( + `Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } } res.json({ - message: `Import completed: ${results.success} successful, ${results.failed} failed`, - success: results.success, - failed: results.failed, - errors: results.errors + message: `Import completed: ${results.success} successful, ${results.failed} failed`, + success: results.success, + failed: results.failed, + errors: results.errors, }); -}); + }, +); -export default router; \ No newline at end of file +export default router; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d4b38bac..d945f2b0 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,1402 +1,1620 @@ -import express from 'express'; -import {db} from '../db/index.js'; +import express from "express"; +import { db } from "../db/index.js"; import { - users, - sshData, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - dismissedAlerts -} from '../db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import bcrypt from 'bcryptjs'; -import {nanoid} from 'nanoid'; -import jwt from 'jsonwebtoken'; -import speakeasy from 'speakeasy'; -import QRCode from 'qrcode'; -import type {Request, Response, NextFunction} from 'express'; -import {authLogger, apiLogger} from '../../utils/logger.js'; + users, + sshData, + fileManagerRecent, + fileManagerPinned, + fileManagerShortcuts, + dismissedAlerts, +} from "../db/schema.js"; +import { eq, and } from "drizzle-orm"; +import bcrypt from "bcryptjs"; +import { nanoid } from "nanoid"; +import jwt from "jsonwebtoken"; +import speakeasy from "speakeasy"; +import QRCode from "qrcode"; +import type { Request, Response, NextFunction } from "express"; +import { authLogger, apiLogger } from "../../utils/logger.js"; + +async function verifyOIDCToken( + idToken: string, + issuerUrl: string, + clientId: string, +): Promise { + try { + const normalizedIssuerUrl = issuerUrl.endsWith("/") + ? issuerUrl.slice(0, -1) + : issuerUrl; + const possibleIssuers = [ + issuerUrl, + normalizedIssuerUrl, + issuerUrl.replace(/\/application\/o\/[^\/]+$/, ""), + normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ""), + ]; + + const jwksUrls = [ + `${normalizedIssuerUrl}/.well-known/jwks.json`, + `${normalizedIssuerUrl}/jwks/`, + `${normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, "")}/.well-known/jwks.json`, + ]; -async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { try { - const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; - const possibleIssuers = [ - issuerUrl, - normalizedIssuerUrl, - issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''), - normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '') - ]; - - const jwksUrls = [ - `${normalizedIssuerUrl}/.well-known/jwks.json`, - `${normalizedIssuerUrl}/jwks/`, - `${normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')}/.well-known/jwks.json` - ]; - - try { - const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; - const discoveryResponse = await fetch(discoveryUrl); - if (discoveryResponse.ok) { - const discovery = await discoveryResponse.json() as any; - if (discovery.jwks_uri) { - jwksUrls.unshift(discovery.jwks_uri); - } - } - } catch (discoveryError) { - authLogger.error(`OIDC discovery failed: ${discoveryError}`); + const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; + const discoveryResponse = await fetch(discoveryUrl); + if (discoveryResponse.ok) { + const discovery = (await discoveryResponse.json()) as any; + if (discovery.jwks_uri) { + jwksUrls.unshift(discovery.jwks_uri); } - - let jwks: any = null; - let jwksUrl: string | null = null; - - for (const url of jwksUrls) { - try { - const response = await fetch(url); - if (response.ok) { - const jwksData = await response.json() as any; - if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { - jwks = jwksData; - jwksUrl = url; - break; - } else { - authLogger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`); - } - } else { - authLogger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`); - } - } catch (error) { - authLogger.error(`JWKS fetch error from ${url}:`, error); - continue; - } - } - - if (!jwks) { - throw new Error('Failed to fetch JWKS from any URL'); - } - - if (!jwks.keys || !Array.isArray(jwks.keys)) { - throw new Error(`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`); - } - - const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); - const keyId = header.kid; - - const publicKey = jwks.keys.find((key: any) => key.kid === keyId); - if (!publicKey) { - throw new Error(`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: any) => k.kid).join(', ')}`); - } - - const {importJWK, jwtVerify} = await import('jose'); - const key = await importJWK(publicKey); - - const {payload} = await jwtVerify(idToken, key, { - issuer: possibleIssuers, - audience: clientId, - }); - - return payload; - } catch (error) { - authLogger.error('OIDC token verification failed:', error); - throw error; + } + } catch (discoveryError) { + authLogger.error(`OIDC discovery failed: ${discoveryError}`); } -} + let jwks: any = null; + let jwksUrl: string | null = null; + + for (const url of jwksUrls) { + try { + const response = await fetch(url); + if (response.ok) { + const jwksData = (await response.json()) as any; + if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { + jwks = jwksData; + jwksUrl = url; + break; + } else { + authLogger.error( + `Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`, + ); + } + } else { + authLogger.error( + `JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`, + ); + } + } catch (error) { + authLogger.error(`JWKS fetch error from ${url}:`, error); + continue; + } + } + + if (!jwks) { + throw new Error("Failed to fetch JWKS from any URL"); + } + + if (!jwks.keys || !Array.isArray(jwks.keys)) { + throw new Error( + `Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`, + ); + } + + const header = JSON.parse( + Buffer.from(idToken.split(".")[0], "base64").toString(), + ); + const keyId = header.kid; + + const publicKey = jwks.keys.find((key: any) => key.kid === keyId); + if (!publicKey) { + throw new Error( + `No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: any) => k.kid).join(", ")}`, + ); + } + + const { importJWK, jwtVerify } = await import("jose"); + const key = await importJWK(publicKey); + + const { payload } = await jwtVerify(idToken, key, { + issuer: possibleIssuers, + audience: clientId, + }); + + return payload; + } catch (error) { + authLogger.error("OIDC token verification failed:", error); + throw error; + } +} const router = express.Router(); function isNonEmptyString(val: any): val is string { - return typeof val === 'string' && val.trim().length > 0; + return typeof val === "string" && val.trim().length > 0; } interface JWTPayload { - userId: string; - iat?: number; - exp?: number; + userId: string; + iat?: number; + exp?: number; } // JWT authentication middleware function authenticateJWT(req: Request, res: Response, next: NextFunction) { - const authHeader = req.headers['authorization']; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - authLogger.warn('Missing or invalid Authorization header', { - operation: 'auth', - method: req.method, - url: req.url - }); - return res.status(401).json({error: 'Missing or invalid Authorization header'}); - } - const token = authHeader.split(' ')[1]; - const jwtSecret = process.env.JWT_SECRET || 'secret'; - try { - const payload = jwt.verify(token, jwtSecret) as JWTPayload; - (req as any).userId = payload.userId; - next(); - } catch (err) { - authLogger.warn('Invalid or expired token', {operation: 'auth', method: req.method, url: req.url, error: err}); - return res.status(401).json({error: 'Invalid or expired token'}); - } + const authHeader = req.headers["authorization"]; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + authLogger.warn("Missing or invalid Authorization header", { + operation: "auth", + method: req.method, + url: req.url, + }); + return res + .status(401) + .json({ error: "Missing or invalid Authorization header" }); + } + const token = authHeader.split(" ")[1]; + const jwtSecret = process.env.JWT_SECRET || "secret"; + try { + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + (req as any).userId = payload.userId; + next(); + } catch (err) { + authLogger.warn("Invalid or expired token", { + operation: "auth", + method: req.method, + url: req.url, + error: err, + }); + return res.status(401).json({ error: "Invalid or expired token" }); + } } // Route: Create traditional user (username/password) // POST /users/create -router.post('/create', async (req, res) => { +router.post("/create", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") + .get(); + if (row && (row as any).value !== "true") { + return res + .status(403) + .json({ error: "Registration is currently disabled" }); + } + } catch (e) { + authLogger.warn("Failed to check registration status", { + operation: "registration_check", + error: e, + }); + } + + const { username, password } = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { + authLogger.warn( + "Invalid user creation attempt - missing username or password", + { + operation: "user_create", + hasUsername: !!username, + hasPassword: !!password, + }, + ); + return res + .status(400) + .json({ error: "Username and password are required" }); + } + + try { + const existing = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (existing && existing.length > 0) { + authLogger.warn(`Attempt to create duplicate username: ${username}`, { + operation: "user_create", + username, + }); + return res.status(409).json({ error: "Username already exists" }); + } + + let isFirstUser = false; try { - const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); - if (row && (row as any).value !== 'true') { - return res.status(403).json({error: 'Registration is currently disabled'}); - } + const countResult = db.$client + .prepare("SELECT COUNT(*) as count FROM users") + .get(); + isFirstUser = ((countResult as any)?.count || 0) === 0; } catch (e) { - authLogger.warn('Failed to check registration status', {operation: 'registration_check', error: e}); + isFirstUser = true; + authLogger.warn("Failed to check user count, assuming first user", { + operation: "user_create", + username, + error: e, + }); } - const {username, password} = req.body; + const saltRounds = parseInt(process.env.SALT || "10", 10); + const password_hash = await bcrypt.hash(password, saltRounds); + const id = nanoid(); + await db.insert(users).values({ + id, + username, + password_hash, + is_admin: isFirstUser, + is_oidc: false, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + totp_secret: null, + totp_enabled: false, + totp_backup_codes: null, + }); - if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - authLogger.warn('Invalid user creation attempt - missing username or password', { - operation: 'user_create', - hasUsername: !!username, - hasPassword: !!password - }); - return res.status(400).json({error: 'Username and password are required'}); - } - - try { - const existing = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (existing && existing.length > 0) { - authLogger.warn(`Attempt to create duplicate username: ${username}`, {operation: 'user_create', username}); - return res.status(409).json({error: 'Username already exists'}); - } - - let isFirstUser = false; - try { - const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); - isFirstUser = ((countResult as any)?.count || 0) === 0; - } catch (e) { - isFirstUser = true; - authLogger.warn('Failed to check user count, assuming first user', { - operation: 'user_create', - username, - error: e - }); - } - - const saltRounds = parseInt(process.env.SALT || '10', 10); - const password_hash = await bcrypt.hash(password, saltRounds); - const id = nanoid(); - await db.insert(users).values({ - id, - username, - password_hash, - is_admin: isFirstUser, - is_oidc: false, - client_id: '', - client_secret: '', - issuer_url: '', - authorization_url: '', - token_url: '', - identifier_path: '', - name_path: '', - scopes: 'openid email profile', - totp_secret: null, - totp_enabled: false, - totp_backup_codes: null, - }); - - authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { - operation: 'user_create', - username, - isAdmin: isFirstUser, - userId: id - }); - res.json({ - message: 'User created', - is_admin: isFirstUser, - toast: {type: 'success', message: `User created: ${username}`} - }); - } catch (err) { - authLogger.error('Failed to create user', err); - res.status(500).json({error: 'Failed to create user'}); - } + authLogger.success( + `Traditional user created: ${username} (is_admin: ${isFirstUser})`, + { + operation: "user_create", + username, + isAdmin: isFirstUser, + userId: id, + }, + ); + res.json({ + message: "User created", + is_admin: isFirstUser, + toast: { type: "success", message: `User created: ${username}` }, + }); + } catch (err) { + authLogger.error("Failed to create user", err); + res.status(500).json({ error: "Failed to create user" }); + } }); // Route: Create OIDC provider configuration (admin only) // POST /users/oidc-config -router.post('/oidc-config', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - - const { - client_id, - client_secret, - issuer_url, - authorization_url, - token_url, - userinfo_url, - identifier_path, - name_path, - scopes - } = req.body; - - const isDisableRequest = (client_id === '' || client_id === null || client_id === undefined) && - (client_secret === '' || client_secret === null || client_secret === undefined) && - (issuer_url === '' || issuer_url === null || issuer_url === undefined) && - (authorization_url === '' || authorization_url === null || authorization_url === undefined) && - (token_url === '' || token_url === null || token_url === undefined); - - const isEnableRequest = isNonEmptyString(client_id) && isNonEmptyString(client_secret) && - isNonEmptyString(issuer_url) && isNonEmptyString(authorization_url) && - isNonEmptyString(token_url) && isNonEmptyString(identifier_path) && - isNonEmptyString(name_path); - - if (!isDisableRequest && !isEnableRequest) { - authLogger.warn('OIDC validation failed - neither disable nor enable request', { - operation: 'oidc_config_update', - userId, - isDisableRequest, - isEnableRequest - }); - return res.status(400).json({error: 'All OIDC configuration fields are required'}); - } - - if (isDisableRequest) { - db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); - authLogger.info('OIDC configuration disabled', {operation: 'oidc_disable', userId}); - res.json({message: 'OIDC configuration disabled'}); - } else { - const config = { - client_id, - client_secret, - issuer_url, - authorization_url, - token_url, - userinfo_url: userinfo_url || '', - identifier_path, - name_path, - scopes: scopes || 'openid email profile' - }; - - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config)); - authLogger.info('OIDC configuration updated', { - operation: 'oidc_update', - userId, - hasUserinfoUrl: !!userinfo_url - }); - res.json({message: 'OIDC configuration updated'}); - } - } catch (err) { - authLogger.error('Failed to update OIDC config', err); - res.status(500).json({error: 'Failed to update OIDC config'}); +router.post("/oidc-config", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } + + const { + client_id, + client_secret, + issuer_url, + authorization_url, + token_url, + userinfo_url, + identifier_path, + name_path, + scopes, + } = req.body; + + const isDisableRequest = + (client_id === "" || client_id === null || client_id === undefined) && + (client_secret === "" || + client_secret === null || + client_secret === undefined) && + (issuer_url === "" || issuer_url === null || issuer_url === undefined) && + (authorization_url === "" || + authorization_url === null || + authorization_url === undefined) && + (token_url === "" || token_url === null || token_url === undefined); + + const isEnableRequest = + isNonEmptyString(client_id) && + isNonEmptyString(client_secret) && + isNonEmptyString(issuer_url) && + isNonEmptyString(authorization_url) && + isNonEmptyString(token_url) && + isNonEmptyString(identifier_path) && + isNonEmptyString(name_path); + + if (!isDisableRequest && !isEnableRequest) { + authLogger.warn( + "OIDC validation failed - neither disable nor enable request", + { + operation: "oidc_config_update", + userId, + isDisableRequest, + isEnableRequest, + }, + ); + return res + .status(400) + .json({ error: "All OIDC configuration fields are required" }); + } + + if (isDisableRequest) { + db.$client + .prepare("DELETE FROM settings WHERE key = 'oidc_config'") + .run(); + authLogger.info("OIDC configuration disabled", { + operation: "oidc_disable", + userId, + }); + res.json({ message: "OIDC configuration disabled" }); + } else { + const config = { + client_id, + client_secret, + issuer_url, + authorization_url, + token_url, + userinfo_url: userinfo_url || "", + identifier_path, + name_path, + scopes: scopes || "openid email profile", + }; + + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)", + ) + .run(JSON.stringify(config)); + authLogger.info("OIDC configuration updated", { + operation: "oidc_update", + userId, + hasUserinfoUrl: !!userinfo_url, + }); + res.json({ message: "OIDC configuration updated" }); + } + } catch (err) { + authLogger.error("Failed to update OIDC config", err); + res.status(500).json({ error: "Failed to update OIDC config" }); + } }); // Route: Disable OIDC configuration (admin only) // DELETE /users/oidc-config -router.delete('/oidc-config', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - - db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); - authLogger.success('OIDC configuration disabled', {operation: 'oidc_disable', userId}); - res.json({message: 'OIDC configuration disabled'}); - } catch (err) { - authLogger.error('Failed to disable OIDC config', err); - res.status(500).json({error: 'Failed to disable OIDC config'}); +router.delete("/oidc-config", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } + + db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); + authLogger.success("OIDC configuration disabled", { + operation: "oidc_disable", + userId, + }); + res.json({ message: "OIDC configuration disabled" }); + } catch (err) { + authLogger.error("Failed to disable OIDC config", err); + res.status(500).json({ error: "Failed to disable OIDC config" }); + } }); // Route: Get OIDC configuration // GET /users/oidc-config -router.get('/oidc-config', async (req, res) => { - try { - const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get(); - if (!row) { - return res.json(null); - } - res.json(JSON.parse((row as any).value)); - } catch (err) { - authLogger.error('Failed to get OIDC config', err); - res.status(500).json({error: 'Failed to get OIDC config'}); +router.get("/oidc-config", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") + .get(); + if (!row) { + return res.json(null); } + res.json(JSON.parse((row as any).value)); + } catch (err) { + authLogger.error("Failed to get OIDC config", err); + res.status(500).json({ error: "Failed to get OIDC config" }); + } }); // Route: Get OIDC authorization URL // GET /users/oidc/authorize -router.get('/oidc/authorize', async (req, res) => { - try { - const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get(); - if (!row) { - return res.status(404).json({error: 'OIDC not configured'}); - } - - const config = JSON.parse((row as any).value); - const state = nanoid(); - const nonce = nanoid(); - - let origin = req.get('Origin') || req.get('Referer')?.replace(/\/[^\/]*$/, '') || 'http://localhost:5173'; - - if (origin.includes('localhost')) { - origin = 'http://localhost:8081'; - } - - const redirectUri = `${origin}/users/oidc/callback`; - - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_state_${state}`, nonce); - - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_redirect_${state}`, redirectUri); - - const authUrl = new URL(config.authorization_url); - authUrl.searchParams.set('client_id', config.client_id); - authUrl.searchParams.set('redirect_uri', redirectUri); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', config.scopes); - authUrl.searchParams.set('state', state); - authUrl.searchParams.set('nonce', nonce); - - res.json({auth_url: authUrl.toString(), state, nonce}); - } catch (err) { - authLogger.error('Failed to generate OIDC auth URL', err); - res.status(500).json({error: 'Failed to generate authorization URL'}); +router.get("/oidc/authorize", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") + .get(); + if (!row) { + return res.status(404).json({ error: "OIDC not configured" }); } + + const config = JSON.parse((row as any).value); + const state = nanoid(); + const nonce = nanoid(); + + let origin = + req.get("Origin") || + req.get("Referer")?.replace(/\/[^\/]*$/, "") || + "http://localhost:5173"; + + if (origin.includes("localhost")) { + origin = "http://localhost:8081"; + } + + const redirectUri = `${origin}/users/oidc/callback`; + + db.$client + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run(`oidc_state_${state}`, nonce); + + db.$client + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run(`oidc_redirect_${state}`, redirectUri); + + const authUrl = new URL(config.authorization_url); + authUrl.searchParams.set("client_id", config.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", config.scopes); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("nonce", nonce); + + res.json({ auth_url: authUrl.toString(), state, nonce }); + } catch (err) { + authLogger.error("Failed to generate OIDC auth URL", err); + res.status(500).json({ error: "Failed to generate authorization URL" }); + } }); // Route: OIDC callback - exchange code for token and create/login user // GET /users/oidc/callback -router.get('/oidc/callback', async (req, res) => { - const {code, state} = req.query; +router.get("/oidc/callback", async (req, res) => { + const { code, state } = req.query; - if (!isNonEmptyString(code) || !isNonEmptyString(state)) { - return res.status(400).json({error: 'Code and state are required'}); + if (!isNonEmptyString(code) || !isNonEmptyString(state)) { + return res.status(400).json({ error: "Code and state are required" }); + } + + const storedRedirectRow = db.$client + .prepare("SELECT value FROM settings WHERE key = ?") + .get(`oidc_redirect_${state}`); + if (!storedRedirectRow) { + return res + .status(400) + .json({ error: "Invalid state parameter - redirect URI not found" }); + } + const redirectUri = (storedRedirectRow as any).value; + + try { + const storedNonce = db.$client + .prepare("SELECT value FROM settings WHERE key = ?") + .get(`oidc_state_${state}`); + if (!storedNonce) { + return res.status(400).json({ error: "Invalid state parameter" }); } - const storedRedirectRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_redirect_${state}`); - if (!storedRedirectRow) { - return res.status(400).json({error: 'Invalid state parameter - redirect URI not found'}); + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`oidc_state_${state}`); + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`oidc_redirect_${state}`); + + const configRow = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") + .get(); + if (!configRow) { + return res.status(500).json({ error: "OIDC not configured" }); } - const redirectUri = (storedRedirectRow as any).value; + + const config = JSON.parse((configRow as any).value); + + const tokenResponse = await fetch(config.token_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.client_id, + client_secret: config.client_secret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!tokenResponse.ok) { + authLogger.error( + "OIDC token exchange failed", + await tokenResponse.text(), + ); + return res + .status(400) + .json({ error: "Failed to exchange authorization code" }); + } + + const tokenData = (await tokenResponse.json()) as any; + + let userInfo: any = null; + let userInfoUrls: string[] = []; + + const normalizedIssuerUrl = config.issuer_url.endsWith("/") + ? config.issuer_url.slice(0, -1) + : config.issuer_url; + const baseUrl = normalizedIssuerUrl.replace( + /\/application\/o\/[^\/]+$/, + "", + ); try { - const storedNonce = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_state_${state}`); - if (!storedNonce) { - return res.status(400).json({error: 'Invalid state parameter'}); + const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; + const discoveryResponse = await fetch(discoveryUrl); + if (discoveryResponse.ok) { + const discovery = (await discoveryResponse.json()) as any; + if (discovery.userinfo_endpoint) { + userInfoUrls.push(discovery.userinfo_endpoint); } - - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_state_${state}`); - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_redirect_${state}`); - - const configRow = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get(); - if (!configRow) { - return res.status(500).json({error: 'OIDC not configured'}); - } - - const config = JSON.parse((configRow as any).value); - - const tokenResponse = await fetch(config.token_url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: config.client_id, - client_secret: config.client_secret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!tokenResponse.ok) { - authLogger.error('OIDC token exchange failed', await tokenResponse.text()); - return res.status(400).json({error: 'Failed to exchange authorization code'}); - } - - const tokenData = await tokenResponse.json() as any; - - let userInfo: any = null; - let userInfoUrls: string[] = []; - - const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - - try { - const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; - const discoveryResponse = await fetch(discoveryUrl); - if (discoveryResponse.ok) { - const discovery = await discoveryResponse.json() as any; - if (discovery.userinfo_endpoint) { - userInfoUrls.push(discovery.userinfo_endpoint); - } - } - } catch (discoveryError) { - authLogger.error(`OIDC discovery failed: ${discoveryError}`); - } - - if (config.userinfo_url) { - userInfoUrls.unshift(config.userinfo_url); - } - - userInfoUrls.push( - `${baseUrl}/userinfo/`, - `${baseUrl}/userinfo`, - `${normalizedIssuerUrl}/userinfo/`, - `${normalizedIssuerUrl}/userinfo`, - `${baseUrl}/oauth2/userinfo/`, - `${baseUrl}/oauth2/userinfo`, - `${normalizedIssuerUrl}/oauth2/userinfo/`, - `${normalizedIssuerUrl}/oauth2/userinfo` - ); - - if (tokenData.id_token) { - try { - userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); - } catch (error) { - authLogger.error('OIDC token verification failed, trying userinfo endpoints', error); - try { - const parts = tokenData.id_token.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); - userInfo = payload; - } - } catch (decodeError) { - authLogger.error('Failed to decode ID token payload:', decodeError); - } - } - } - - if (!userInfo && tokenData.access_token) { - for (const userInfoUrl of userInfoUrls) { - try { - const userInfoResponse = await fetch(userInfoUrl, { - headers: { - 'Authorization': `Bearer ${tokenData.access_token}`, - } - }); - - if (userInfoResponse.ok) { - userInfo = await userInfoResponse.json(); - break; - } else { - authLogger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`); - } - } catch (error) { - authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); - continue; - } - } - } - - if (!userInfo) { - authLogger.error('Failed to get user information from all sources'); - authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); - authLogger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`); - authLogger.error(`Has id_token: ${!!tokenData.id_token}`); - authLogger.error(`Has access_token: ${!!tokenData.access_token}`); - return res.status(400).json({error: 'Failed to get user information'}); - } - - const getNestedValue = (obj: any, path: string): any => { - if (!path || !obj) return null; - return path.split('.').reduce((current, key) => current?.[key], obj); - }; - - const identifier = getNestedValue(userInfo, config.identifier_path) || - userInfo[config.identifier_path] || - userInfo.sub || - userInfo.email || - userInfo.preferred_username; - - const name = getNestedValue(userInfo, config.name_path) || - userInfo[config.name_path] || - userInfo.name || - userInfo.given_name || - identifier; - - if (!identifier) { - authLogger.error(`Identifier not found at path: ${config.identifier_path}`); - authLogger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`); - return res.status(400).json({error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(', ')}`}); - } - - let user = await db - .select() - .from(users) - .where(and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier))); - - let isFirstUser = false; - if (!user || user.length === 0) { - try { - const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); - isFirstUser = ((countResult as any)?.count || 0) === 0; - } catch (e) { - isFirstUser = true; - } - - const id = nanoid(); - await db.insert(users).values({ - id, - username: name, - password_hash: '', - is_admin: isFirstUser, - is_oidc: true, - oidc_identifier: identifier, - client_id: config.client_id, - client_secret: config.client_secret, - issuer_url: config.issuer_url, - authorization_url: config.authorization_url, - token_url: config.token_url, - identifier_path: config.identifier_path, - name_path: config.name_path, - scopes: config.scopes, - }); - - user = await db - .select() - .from(users) - .where(eq(users.id, id)); - } else { - await db.update(users) - .set({username: name}) - .where(eq(users.id, user[0].id)); - - user = await db - .select() - .from(users) - .where(eq(users.id, user[0].id)); - } - - const userRecord = user[0]; - - const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({userId: userRecord.id}, jwtSecret, { - expiresIn: '50d', - }); - - let frontendUrl = redirectUri.replace('/users/oidc/callback', ''); - - if (frontendUrl.includes('localhost')) { - frontendUrl = 'http://localhost:5173'; - } - - const redirectUrl = new URL(frontendUrl); - redirectUrl.searchParams.set('success', 'true'); - redirectUrl.searchParams.set('token', token); - - res.redirect(redirectUrl.toString()); - - } catch (err) { - authLogger.error('OIDC callback failed', err); - - let frontendUrl = redirectUri.replace('/users/oidc/callback', ''); - - if (frontendUrl.includes('localhost')) { - frontendUrl = 'http://localhost:5173'; - } - - const redirectUrl = new URL(frontendUrl); - redirectUrl.searchParams.set('error', 'OIDC authentication failed'); - - res.redirect(redirectUrl.toString()); + } + } catch (discoveryError) { + authLogger.error(`OIDC discovery failed: ${discoveryError}`); } + + if (config.userinfo_url) { + userInfoUrls.unshift(config.userinfo_url); + } + + userInfoUrls.push( + `${baseUrl}/userinfo/`, + `${baseUrl}/userinfo`, + `${normalizedIssuerUrl}/userinfo/`, + `${normalizedIssuerUrl}/userinfo`, + `${baseUrl}/oauth2/userinfo/`, + `${baseUrl}/oauth2/userinfo`, + `${normalizedIssuerUrl}/oauth2/userinfo/`, + `${normalizedIssuerUrl}/oauth2/userinfo`, + ); + + if (tokenData.id_token) { + try { + userInfo = await verifyOIDCToken( + tokenData.id_token, + config.issuer_url, + config.client_id, + ); + } catch (error) { + authLogger.error( + "OIDC token verification failed, trying userinfo endpoints", + error, + ); + try { + const parts = tokenData.id_token.split("."); + if (parts.length === 3) { + const payload = JSON.parse( + Buffer.from(parts[1], "base64").toString(), + ); + userInfo = payload; + } + } catch (decodeError) { + authLogger.error("Failed to decode ID token payload:", decodeError); + } + } + } + + if (!userInfo && tokenData.access_token) { + for (const userInfoUrl of userInfoUrls) { + try { + const userInfoResponse = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (userInfoResponse.ok) { + userInfo = await userInfoResponse.json(); + break; + } else { + authLogger.error( + `Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`, + ); + } + } catch (error) { + authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); + continue; + } + } + } + + if (!userInfo) { + authLogger.error("Failed to get user information from all sources"); + authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(", ")}`); + authLogger.error(`Token data keys: ${Object.keys(tokenData).join(", ")}`); + authLogger.error(`Has id_token: ${!!tokenData.id_token}`); + authLogger.error(`Has access_token: ${!!tokenData.access_token}`); + return res.status(400).json({ error: "Failed to get user information" }); + } + + const getNestedValue = (obj: any, path: string): any => { + if (!path || !obj) return null; + return path.split(".").reduce((current, key) => current?.[key], obj); + }; + + const identifier = + getNestedValue(userInfo, config.identifier_path) || + userInfo[config.identifier_path] || + userInfo.sub || + userInfo.email || + userInfo.preferred_username; + + const name = + getNestedValue(userInfo, config.name_path) || + userInfo[config.name_path] || + userInfo.name || + userInfo.given_name || + identifier; + + if (!identifier) { + authLogger.error( + `Identifier not found at path: ${config.identifier_path}`, + ); + authLogger.error(`Available fields: ${Object.keys(userInfo).join(", ")}`); + return res + .status(400) + .json({ + error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`, + }); + } + + let user = await db + .select() + .from(users) + .where( + and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)), + ); + + let isFirstUser = false; + if (!user || user.length === 0) { + try { + const countResult = db.$client + .prepare("SELECT COUNT(*) as count FROM users") + .get(); + isFirstUser = ((countResult as any)?.count || 0) === 0; + } catch (e) { + isFirstUser = true; + } + + const id = nanoid(); + await db.insert(users).values({ + id, + username: name, + password_hash: "", + is_admin: isFirstUser, + is_oidc: true, + oidc_identifier: identifier, + client_id: config.client_id, + client_secret: config.client_secret, + issuer_url: config.issuer_url, + authorization_url: config.authorization_url, + token_url: config.token_url, + identifier_path: config.identifier_path, + name_path: config.name_path, + scopes: config.scopes, + }); + + user = await db.select().from(users).where(eq(users.id, id)); + } else { + await db + .update(users) + .set({ username: name }) + .where(eq(users.id, user[0].id)); + + user = await db.select().from(users).where(eq(users.id, user[0].id)); + } + + const userRecord = user[0]; + + const jwtSecret = process.env.JWT_SECRET || "secret"; + const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + expiresIn: "50d", + }); + + let frontendUrl = redirectUri.replace("/users/oidc/callback", ""); + + if (frontendUrl.includes("localhost")) { + frontendUrl = "http://localhost:5173"; + } + + const redirectUrl = new URL(frontendUrl); + redirectUrl.searchParams.set("success", "true"); + redirectUrl.searchParams.set("token", token); + + res.redirect(redirectUrl.toString()); + } catch (err) { + authLogger.error("OIDC callback failed", err); + + let frontendUrl = redirectUri.replace("/users/oidc/callback", ""); + + if (frontendUrl.includes("localhost")) { + frontendUrl = "http://localhost:5173"; + } + + const redirectUrl = new URL(frontendUrl); + redirectUrl.searchParams.set("error", "OIDC authentication failed"); + + res.redirect(redirectUrl.toString()); + } }); // Route: Get user JWT by username and password (traditional login) // POST /users/login -router.post('/login', async (req, res) => { - const {username, password} = req.body; +router.post("/login", async (req, res) => { + const { username, password } = req.body; - if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - authLogger.warn('Invalid traditional login attempt', { - operation: 'user_login', - hasUsername: !!username, - hasPassword: !!password - }); - return res.status(400).json({error: 'Invalid username or password'}); + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { + authLogger.warn("Invalid traditional login attempt", { + operation: "user_login", + hasUsername: !!username, + hasPassword: !!password, + }); + return res.status(400).json({ error: "Invalid username or password" }); + } + + try { + const user = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (!user || user.length === 0) { + authLogger.warn(`User not found: ${username}`, { + operation: "user_login", + username, + }); + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); + const userRecord = user[0]; - if (!user || user.length === 0) { - authLogger.warn(`User not found: ${username}`, {operation: 'user_login', username}); - return res.status(404).json({error: 'User not found'}); - } - - const userRecord = user[0]; - - if (userRecord.is_oidc) { - authLogger.warn('OIDC user attempted traditional login', { - operation: 'user_login', - username, - userId: userRecord.id - }); - return res.status(403).json({error: 'This user uses external authentication'}); - } - - const isMatch = await bcrypt.compare(password, userRecord.password_hash); - if (!isMatch) { - authLogger.warn(`Incorrect password for user: ${username}`, { - operation: 'user_login', - username, - userId: userRecord.id - }); - return res.status(401).json({error: 'Incorrect password'}); - } - const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({userId: userRecord.id}, jwtSecret, { - expiresIn: '50d', - }); - - if (userRecord.totp_enabled) { - const tempToken = jwt.sign( - {userId: userRecord.id, pending_totp: true}, - jwtSecret, - {expiresIn: '10m'} - ); - return res.json({ - requires_totp: true, - temp_token: tempToken - }); - } - return res.json({ - token, - is_admin: !!userRecord.is_admin, - username: userRecord.username - }); - - } catch (err) { - authLogger.error('Failed to log in user', err); - return res.status(500).json({error: 'Login failed'}); + if (userRecord.is_oidc) { + authLogger.warn("OIDC user attempted traditional login", { + operation: "user_login", + username, + userId: userRecord.id, + }); + return res + .status(403) + .json({ error: "This user uses external authentication" }); } + + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + authLogger.warn(`Incorrect password for user: ${username}`, { + operation: "user_login", + username, + userId: userRecord.id, + }); + return res.status(401).json({ error: "Incorrect password" }); + } + const jwtSecret = process.env.JWT_SECRET || "secret"; + const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + expiresIn: "50d", + }); + + if (userRecord.totp_enabled) { + const tempToken = jwt.sign( + { userId: userRecord.id, pending_totp: true }, + jwtSecret, + { expiresIn: "10m" }, + ); + return res.json({ + requires_totp: true, + temp_token: tempToken, + }); + } + return res.json({ + token, + is_admin: !!userRecord.is_admin, + username: userRecord.username, + }); + } catch (err) { + authLogger.error("Failed to log in user", err); + return res.status(500).json({ error: "Login failed" }); + } }); // Route: Get current user's info using JWT // GET /users/me -router.get('/me', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - authLogger.warn('Invalid userId in JWT for /users/me'); - return res.status(401).json({error: 'Invalid userId'}); - } - try { - const user = await db - .select() - .from(users) - .where(eq(users.id, userId)); - if (!user || user.length === 0) { - authLogger.warn(`User not found for /users/me: ${userId}`); - return res.status(401).json({error: 'User not found'}); - } - res.json({ - userId: user[0].id, - username: user[0].username, - is_admin: !!user[0].is_admin, - is_oidc: !!user[0].is_oidc, - totp_enabled: !!user[0].totp_enabled - }); - } catch (err) { - authLogger.error('Failed to get username', err); - res.status(500).json({error: 'Failed to get username'}); +router.get("/me", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId in JWT for /users/me"); + return res.status(401).json({ error: "Invalid userId" }); + } + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + authLogger.warn(`User not found for /users/me: ${userId}`); + return res.status(401).json({ error: "User not found" }); } + res.json({ + userId: user[0].id, + username: user[0].username, + is_admin: !!user[0].is_admin, + is_oidc: !!user[0].is_oidc, + totp_enabled: !!user[0].totp_enabled, + }); + } catch (err) { + authLogger.error("Failed to get username", err); + res.status(500).json({ error: "Failed to get username" }); + } }); // Route: Count users // GET /users/count -router.get('/count', async (req, res) => { - try { - const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); - const count = (countResult as any)?.count || 0; - res.json({count}); - } catch (err) { - authLogger.error('Failed to count users', err); - res.status(500).json({error: 'Failed to count users'}); - } +router.get("/count", async (req, res) => { + try { + const countResult = db.$client + .prepare("SELECT COUNT(*) as count FROM users") + .get(); + const count = (countResult as any)?.count || 0; + res.json({ count }); + } catch (err) { + authLogger.error("Failed to count users", err); + res.status(500).json({ error: "Failed to count users" }); + } }); // Route: DB health check (actually queries DB) // GET /users/db-health -router.get('/db-health', async (req, res) => { - try { - db.$client.prepare('SELECT 1').get(); - res.json({status: 'ok'}); - } catch (err) { - authLogger.error('DB health check failed', err); - res.status(500).json({error: 'Database not accessible'}); - } +router.get("/db-health", async (req, res) => { + try { + db.$client.prepare("SELECT 1").get(); + res.json({ status: "ok" }); + } catch (err) { + authLogger.error("DB health check failed", err); + res.status(500).json({ error: "Database not accessible" }); + } }); // Route: Get registration allowed status // GET /users/registration-allowed -router.get('/registration-allowed', async (req, res) => { - try { - const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); - res.json({allowed: row ? (row as any).value === 'true' : true}); - } catch (err) { - authLogger.error('Failed to get registration allowed', err); - res.status(500).json({error: 'Failed to get registration allowed'}); - } +router.get("/registration-allowed", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") + .get(); + res.json({ allowed: row ? (row as any).value === "true" : true }); + } catch (err) { + authLogger.error("Failed to get registration allowed", err); + res.status(500).json({ error: "Failed to get registration allowed" }); + } }); // Route: Set registration allowed status (admin only) // PATCH /users/registration-allowed -router.patch('/registration-allowed', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - const {allowed} = req.body; - if (typeof allowed !== 'boolean') { - return res.status(400).json({error: 'Invalid value for allowed'}); - } - db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false'); - res.json({allowed}); - } catch (err) { - authLogger.error('Failed to set registration allowed', err); - res.status(500).json({error: 'Failed to set registration allowed'}); +router.patch("/registration-allowed", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } + const { allowed } = req.body; + if (typeof allowed !== "boolean") { + return res.status(400).json({ error: "Invalid value for allowed" }); + } + db.$client + .prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'") + .run(allowed ? "true" : "false"); + res.json({ allowed }); + } catch (err) { + authLogger.error("Failed to set registration allowed", err); + res.status(500).json({ error: "Failed to set registration allowed" }); + } }); // Route: Delete user account // DELETE /users/delete-account -router.delete('/delete-account', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {password} = req.body; +router.delete("/delete-account", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { password } = req.body; - if (!isNonEmptyString(password)) { - return res.status(400).json({error: 'Password is required to delete account'}); + if (!isNonEmptyString(password)) { + return res + .status(400) + .json({ error: "Password is required to delete account" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } + const userRecord = user[0]; - const userRecord = user[0]; - - if (userRecord.is_oidc) { - return res.status(403).json({error: 'Cannot delete external authentication accounts through this endpoint'}); - } - - const isMatch = await bcrypt.compare(password, userRecord.password_hash); - if (!isMatch) { - authLogger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`); - return res.status(401).json({error: 'Incorrect password'}); - } - - if (userRecord.is_admin) { - const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); - if ((adminCount as any)?.count <= 1) { - return res.status(403).json({error: 'Cannot delete the last admin user'}); - } - } - - await db.delete(users).where(eq(users.id, userId)); - - authLogger.success(`User account deleted: ${userRecord.username}`); - res.json({message: 'Account deleted successfully'}); - - } catch (err) { - authLogger.error('Failed to delete user account', err); - res.status(500).json({error: 'Failed to delete account'}); + if (userRecord.is_oidc) { + return res + .status(403) + .json({ + error: + "Cannot delete external authentication accounts through this endpoint", + }); } + + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + authLogger.warn( + `Incorrect password provided for account deletion: ${userRecord.username}`, + ); + return res.status(401).json({ error: "Incorrect password" }); + } + + if (userRecord.is_admin) { + const adminCount = db.$client + .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") + .get(); + if ((adminCount as any)?.count <= 1) { + return res + .status(403) + .json({ error: "Cannot delete the last admin user" }); + } + } + + await db.delete(users).where(eq(users.id, userId)); + + authLogger.success(`User account deleted: ${userRecord.username}`); + res.json({ message: "Account deleted successfully" }); + } catch (err) { + authLogger.error("Failed to delete user account", err); + res.status(500).json({ error: "Failed to delete account" }); + } }); // Route: Initiate password reset // POST /users/initiate-reset -router.post('/initiate-reset', async (req, res) => { - const {username} = req.body; +router.post("/initiate-reset", async (req, res) => { + const { username } = req.body; - if (!isNonEmptyString(username)) { - return res.status(400).json({error: 'Username is required'}); + if (!isNonEmptyString(username)) { + return res.status(400).json({ error: "Username is required" }); + } + + try { + const user = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (!user || user.length === 0) { + authLogger.warn( + `Password reset attempted for non-existent user: ${username}`, + ); + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); - - if (!user || user.length === 0) { - authLogger.warn(`Password reset attempted for non-existent user: ${username}`); - return res.status(404).json({error: 'User not found'}); - } - - if (user[0].is_oidc) { - return res.status(403).json({error: 'Password reset not available for external authentication users'}); - } - - const resetCode = Math.floor(100000 + Math.random() * 900000).toString(); - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( - `reset_code_${username}`, - JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()}) - ); - - authLogger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`); - - res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'}); - - } catch (err) { - authLogger.error('Failed to initiate password reset', err); - res.status(500).json({error: 'Failed to initiate password reset'}); + if (user[0].is_oidc) { + return res + .status(403) + .json({ + error: + "Password reset not available for external authentication users", + }); } + + const resetCode = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + db.$client + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run( + `reset_code_${username}`, + JSON.stringify({ code: resetCode, expiresAt: expiresAt.toISOString() }), + ); + + authLogger.info( + `Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`, + ); + + res.json({ + message: + "Password reset code has been generated and logged. Check docker logs for the code.", + }); + } catch (err) { + authLogger.error("Failed to initiate password reset", err); + res.status(500).json({ error: "Failed to initiate password reset" }); + } }); // Route: Verify reset code // POST /users/verify-reset-code -router.post('/verify-reset-code', async (req, res) => { - const {username, resetCode} = req.body; +router.post("/verify-reset-code", async (req, res) => { + const { username, resetCode } = req.body; - if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) { - return res.status(400).json({error: 'Username and reset code are required'}); + if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) { + return res + .status(400) + .json({ error: "Username and reset code are required" }); + } + + try { + const resetDataRow = db.$client + .prepare("SELECT value FROM settings WHERE key = ?") + .get(`reset_code_${username}`); + if (!resetDataRow) { + return res + .status(400) + .json({ error: "No reset code found for this user" }); } - try { - const resetDataRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`reset_code_${username}`); - if (!resetDataRow) { - return res.status(400).json({error: 'No reset code found for this user'}); - } + const resetData = JSON.parse((resetDataRow as any).value); + const now = new Date(); + const expiresAt = new Date(resetData.expiresAt); - const resetData = JSON.parse((resetDataRow as any).value); - const now = new Date(); - const expiresAt = new Date(resetData.expiresAt); - - if (now > expiresAt) { - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); - return res.status(400).json({error: 'Reset code has expired'}); - } - - if (resetData.code !== resetCode) { - return res.status(400).json({error: 'Invalid reset code'}); - } - - const tempToken = nanoid(); - const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); - - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( - `temp_reset_token_${username}`, - JSON.stringify({token: tempToken, expiresAt: tempTokenExpiry.toISOString()}) - ); - - res.json({message: 'Reset code verified', tempToken}); - - } catch (err) { - authLogger.error('Failed to verify reset code', err); - res.status(500).json({error: 'Failed to verify reset code'}); + if (now > expiresAt) { + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`reset_code_${username}`); + return res.status(400).json({ error: "Reset code has expired" }); } + + if (resetData.code !== resetCode) { + return res.status(400).json({ error: "Invalid reset code" }); + } + + const tempToken = nanoid(); + const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); + + db.$client + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run( + `temp_reset_token_${username}`, + JSON.stringify({ + token: tempToken, + expiresAt: tempTokenExpiry.toISOString(), + }), + ); + + res.json({ message: "Reset code verified", tempToken }); + } catch (err) { + authLogger.error("Failed to verify reset code", err); + res.status(500).json({ error: "Failed to verify reset code" }); + } }); // Route: Complete password reset // POST /users/complete-reset -router.post('/complete-reset', async (req, res) => { - const {username, tempToken, newPassword} = req.body; +router.post("/complete-reset", async (req, res) => { + const { username, tempToken, newPassword } = req.body; - if (!isNonEmptyString(username) || !isNonEmptyString(tempToken) || !isNonEmptyString(newPassword)) { - return res.status(400).json({error: 'Username, temporary token, and new password are required'}); + if ( + !isNonEmptyString(username) || + !isNonEmptyString(tempToken) || + !isNonEmptyString(newPassword) + ) { + return res + .status(400) + .json({ + error: "Username, temporary token, and new password are required", + }); + } + + try { + const tempTokenRow = db.$client + .prepare("SELECT value FROM settings WHERE key = ?") + .get(`temp_reset_token_${username}`); + if (!tempTokenRow) { + return res.status(400).json({ error: "No temporary token found" }); } - try { - const tempTokenRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`temp_reset_token_${username}`); - if (!tempTokenRow) { - return res.status(400).json({error: 'No temporary token found'}); - } + const tempTokenData = JSON.parse((tempTokenRow as any).value); + const now = new Date(); + const expiresAt = new Date(tempTokenData.expiresAt); - const tempTokenData = JSON.parse((tempTokenRow as any).value); - const now = new Date(); - const expiresAt = new Date(tempTokenData.expiresAt); - - if (now > expiresAt) { - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); - return res.status(400).json({error: 'Temporary token has expired'}); - } - - if (tempTokenData.token !== tempToken) { - return res.status(400).json({error: 'Invalid temporary token'}); - } - - const saltRounds = parseInt(process.env.SALT || '10', 10); - const password_hash = await bcrypt.hash(newPassword, saltRounds); - - await db.update(users) - .set({password_hash}) - .where(eq(users.username, username)); - - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); - db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); - - authLogger.success(`Password successfully reset for user: ${username}`); - res.json({message: 'Password has been successfully reset'}); - - } catch (err) { - authLogger.error('Failed to complete password reset', err); - res.status(500).json({error: 'Failed to complete password reset'}); + if (now > expiresAt) { + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`temp_reset_token_${username}`); + return res.status(400).json({ error: "Temporary token has expired" }); } + + if (tempTokenData.token !== tempToken) { + return res.status(400).json({ error: "Invalid temporary token" }); + } + + const saltRounds = parseInt(process.env.SALT || "10", 10); + const password_hash = await bcrypt.hash(newPassword, saltRounds); + + await db + .update(users) + .set({ password_hash }) + .where(eq(users.username, username)); + + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`reset_code_${username}`); + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`temp_reset_token_${username}`); + + authLogger.success(`Password successfully reset for user: ${username}`); + res.json({ message: "Password has been successfully reset" }); + } catch (err) { + authLogger.error("Failed to complete password reset", err); + res.status(500).json({ error: "Failed to complete password reset" }); + } }); // Route: List all users (admin only) // GET /users/list -router.get('/list', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - - const allUsers = await db.select({ - id: users.id, - username: users.username, - is_admin: users.is_admin, - is_oidc: users.is_oidc - }).from(users); - - res.json({users: allUsers}); - } catch (err) { - authLogger.error('Failed to list users', err); - res.status(500).json({error: 'Failed to list users'}); +router.get("/list", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } + + const allUsers = await db + .select({ + id: users.id, + username: users.username, + is_admin: users.is_admin, + is_oidc: users.is_oidc, + }) + .from(users); + + res.json({ users: allUsers }); + } catch (err) { + authLogger.error("Failed to list users", err); + res.status(500).json({ error: "Failed to list users" }); + } }); // Route: Make user admin (admin only) // POST /users/make-admin -router.post('/make-admin', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {username} = req.body; +router.post("/make-admin", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { username } = req.body; - if (!isNonEmptyString(username)) { - return res.status(400).json({error: 'Username is required'}); + if (!isNonEmptyString(username)) { + return res.status(400).json({ error: "Username is required" }); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } - try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - - const targetUser = await db.select().from(users).where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { - return res.status(404).json({error: 'User not found'}); - } - - if (targetUser[0].is_admin) { - return res.status(400).json({error: 'User is already an admin'}); - } - - await db.update(users) - .set({is_admin: true}) - .where(eq(users.username, username)); - - authLogger.success(`User ${username} made admin by ${adminUser[0].username}`); - res.json({message: `User ${username} is now an admin`}); - - } catch (err) { - authLogger.error('Failed to make user admin', err); - res.status(500).json({error: 'Failed to make user admin'}); + const targetUser = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({ error: "User not found" }); } + + if (targetUser[0].is_admin) { + return res.status(400).json({ error: "User is already an admin" }); + } + + await db + .update(users) + .set({ is_admin: true }) + .where(eq(users.username, username)); + + authLogger.success( + `User ${username} made admin by ${adminUser[0].username}`, + ); + res.json({ message: `User ${username} is now an admin` }); + } catch (err) { + authLogger.error("Failed to make user admin", err); + res.status(500).json({ error: "Failed to make user admin" }); + } }); // Route: Remove admin status (admin only) // POST /users/remove-admin -router.post('/remove-admin', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {username} = req.body; +router.post("/remove-admin", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { username } = req.body; - if (!isNonEmptyString(username)) { - return res.status(400).json({error: 'Username is required'}); + if (!isNonEmptyString(username)) { + return res.status(400).json({ error: "Username is required" }); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } - try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } - - if (adminUser[0].username === username) { - return res.status(400).json({error: 'Cannot remove your own admin status'}); - } - - const targetUser = await db.select().from(users).where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { - return res.status(404).json({error: 'User not found'}); - } - - if (!targetUser[0].is_admin) { - return res.status(400).json({error: 'User is not an admin'}); - } - - await db.update(users) - .set({is_admin: false}) - .where(eq(users.username, username)); - - authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); - res.json({message: `Admin status removed from ${username}`}); - - } catch (err) { - authLogger.error('Failed to remove admin status', err); - res.status(500).json({error: 'Failed to remove admin status'}); + if (adminUser[0].username === username) { + return res + .status(400) + .json({ error: "Cannot remove your own admin status" }); } + + const targetUser = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + if (!targetUser[0].is_admin) { + return res.status(400).json({ error: "User is not an admin" }); + } + + await db + .update(users) + .set({ is_admin: false }) + .where(eq(users.username, username)); + + authLogger.success( + `Admin status removed from ${username} by ${adminUser[0].username}`, + ); + res.json({ message: `Admin status removed from ${username}` }); + } catch (err) { + authLogger.error("Failed to remove admin status", err); + res.status(500).json({ error: "Failed to remove admin status" }); + } }); // Route: Verify TOTP during login // POST /users/totp/verify-login -router.post('/totp/verify-login', async (req, res) => { - const {temp_token, totp_code} = req.body; +router.post("/totp/verify-login", async (req, res) => { + const { temp_token, totp_code } = req.body; - if (!temp_token || !totp_code) { - return res.status(400).json({error: 'Token and TOTP code are required'}); + if (!temp_token || !totp_code) { + return res.status(400).json({ error: "Token and TOTP code are required" }); + } + + const jwtSecret = process.env.JWT_SECRET || "secret"; + + try { + const decoded = jwt.verify(temp_token, jwtSecret) as any; + if (!decoded.pending_totp) { + return res.status(401).json({ error: "Invalid temporary token" }); } - const jwtSecret = process.env.JWT_SECRET || 'secret'; - - try { - const decoded = jwt.verify(temp_token, jwtSecret) as any; - if (!decoded.pending_totp) { - return res.status(401).json({error: 'Invalid temporary token'}); - } - - const user = await db.select().from(users).where(eq(users.id, decoded.userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } - - const userRecord = user[0]; - - if (!userRecord.totp_enabled || !userRecord.totp_secret) { - return res.status(400).json({error: 'TOTP not enabled for this user'}); - } - - const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret, - encoding: 'base32', - token: totp_code, - window: 2 - }); - - if (!verified) { - const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : []; - const backupIndex = backupCodes.indexOf(totp_code); - - if (backupIndex === -1) { - return res.status(401).json({error: 'Invalid TOTP code'}); - } - - backupCodes.splice(backupIndex, 1); - await db.update(users) - .set({totp_backup_codes: JSON.stringify(backupCodes)}) - .where(eq(users.id, userRecord.id)); - } - - const token = jwt.sign({userId: userRecord.id}, jwtSecret, { - expiresIn: '50d', - }); - - return res.json({ - token, - is_admin: !!userRecord.is_admin, - username: userRecord.username - }); - - } catch (err) { - authLogger.error('TOTP verification failed', err); - return res.status(500).json({error: 'TOTP verification failed'}); + const user = await db + .select() + .from(users) + .where(eq(users.id, decoded.userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled || !userRecord.totp_secret) { + return res.status(400).json({ error: "TOTP not enabled for this user" }); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + const backupCodes = userRecord.totp_backup_codes + ? JSON.parse(userRecord.totp_backup_codes) + : []; + const backupIndex = backupCodes.indexOf(totp_code); + + if (backupIndex === -1) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + + backupCodes.splice(backupIndex, 1); + await db + .update(users) + .set({ totp_backup_codes: JSON.stringify(backupCodes) }) + .where(eq(users.id, userRecord.id)); + } + + const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + expiresIn: "50d", + }); + + return res.json({ + token, + is_admin: !!userRecord.is_admin, + username: userRecord.username, + }); + } catch (err) { + authLogger.error("TOTP verification failed", err); + return res.status(500).json({ error: "TOTP verification failed" }); + } }); // Route: Setup TOTP // POST /users/totp/setup -router.post('/totp/setup', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; +router.post("/totp/setup", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } - - const userRecord = user[0]; - - if (userRecord.totp_enabled) { - return res.status(400).json({error: 'TOTP is already enabled'}); - } - - const secret = speakeasy.generateSecret({ - name: `Termix (${userRecord.username})`, - length: 32 - }); - - await db.update(users) - .set({totp_secret: secret.base32}) - .where(eq(users.id, userId)); - - const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ''); - - res.json({ - secret: secret.base32, - qr_code: qrCodeUrl - }); - - } catch (err) { - authLogger.error('Failed to setup TOTP', err); - res.status(500).json({error: 'Failed to setup TOTP'}); + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is already enabled" }); + } + + const secret = speakeasy.generateSecret({ + name: `Termix (${userRecord.username})`, + length: 32, + }); + + await db + .update(users) + .set({ totp_secret: secret.base32 }) + .where(eq(users.id, userId)); + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); + + res.json({ + secret: secret.base32, + qr_code: qrCodeUrl, + }); + } catch (err) { + authLogger.error("Failed to setup TOTP", err); + res.status(500).json({ error: "Failed to setup TOTP" }); + } }); // Route: Enable TOTP // POST /users/totp/enable -router.post('/totp/enable', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {totp_code} = req.body; +router.post("/totp/enable", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { totp_code } = req.body; - if (!totp_code) { - return res.status(400).json({error: 'TOTP code is required'}); + if (!totp_code) { + return res.status(400).json({ error: "TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } + const userRecord = user[0]; - const userRecord = user[0]; - - if (userRecord.totp_enabled) { - return res.status(400).json({error: 'TOTP is already enabled'}); - } - - if (!userRecord.totp_secret) { - return res.status(400).json({error: 'TOTP setup not initiated'}); - } - - const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret, - encoding: 'base32', - token: totp_code, - window: 2 - }); - - if (!verified) { - return res.status(401).json({error: 'Invalid TOTP code'}); - } - - const backupCodes = Array.from({length: 8}, () => - Math.random().toString(36).substring(2, 10).toUpperCase() - ); - - await db.update(users) - .set({ - totp_enabled: true, - totp_backup_codes: JSON.stringify(backupCodes) - }) - .where(eq(users.id, userId)); - - res.json({ - message: 'TOTP enabled successfully', - backup_codes: backupCodes - }); - - } catch (err) { - authLogger.error('Failed to enable TOTP', err); - res.status(500).json({error: 'Failed to enable TOTP'}); + if (userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is already enabled" }); } + + if (!userRecord.totp_secret) { + return res.status(400).json({ error: "TOTP setup not initiated" }); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + + const backupCodes = Array.from({ length: 8 }, () => + Math.random().toString(36).substring(2, 10).toUpperCase(), + ); + + await db + .update(users) + .set({ + totp_enabled: true, + totp_backup_codes: JSON.stringify(backupCodes), + }) + .where(eq(users.id, userId)); + + res.json({ + message: "TOTP enabled successfully", + backup_codes: backupCodes, + }); + } catch (err) { + authLogger.error("Failed to enable TOTP", err); + res.status(500).json({ error: "Failed to enable TOTP" }); + } }); // Route: Disable TOTP // POST /users/totp/disable -router.post('/totp/disable', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {password, totp_code} = req.body; +router.post("/totp/disable", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { password, totp_code } = req.body; - if (!password && !totp_code) { - return res.status(400).json({error: 'Password or TOTP code is required'}); + if (!password && !totp_code) { + return res.status(400).json({ error: "Password or TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } + const userRecord = user[0]; - const userRecord = user[0]; - - if (!userRecord.totp_enabled) { - return res.status(400).json({error: 'TOTP is not enabled'}); - } - - if (password && !userRecord.is_oidc) { - const isMatch = await bcrypt.compare(password, userRecord.password_hash); - if (!isMatch) { - return res.status(401).json({error: 'Incorrect password'}); - } - } else if (totp_code) { - const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret!, - encoding: 'base32', - token: totp_code, - window: 2 - }); - - if (!verified) { - return res.status(401).json({error: 'Invalid TOTP code'}); - } - } else { - return res.status(400).json({error: 'Authentication required'}); - } - - await db.update(users) - .set({ - totp_enabled: false, - totp_secret: null, - totp_backup_codes: null - }) - .where(eq(users.id, userId)); - - res.json({message: 'TOTP disabled successfully'}); - - } catch (err) { - authLogger.error('Failed to disable TOTP', err); - res.status(500).json({error: 'Failed to disable TOTP'}); + if (!userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is not enabled" }); } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({ error: "Incorrect password" }); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + } else { + return res.status(400).json({ error: "Authentication required" }); + } + + await db + .update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null, + }) + .where(eq(users.id, userId)); + + res.json({ message: "TOTP disabled successfully" }); + } catch (err) { + authLogger.error("Failed to disable TOTP", err); + res.status(500).json({ error: "Failed to disable TOTP" }); + } }); // Route: Generate new backup codes // POST /users/totp/backup-codes -router.post('/totp/backup-codes', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {password, totp_code} = req.body; +router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { password, totp_code } = req.body; - if (!password && !totp_code) { - return res.status(400).json({error: 'Password or TOTP code is required'}); + if (!password && !totp_code) { + return res.status(400).json({ error: "Password or TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - return res.status(404).json({error: 'User not found'}); - } + const userRecord = user[0]; - const userRecord = user[0]; - - if (!userRecord.totp_enabled) { - return res.status(400).json({error: 'TOTP is not enabled'}); - } - - if (password && !userRecord.is_oidc) { - const isMatch = await bcrypt.compare(password, userRecord.password_hash); - if (!isMatch) { - return res.status(401).json({error: 'Incorrect password'}); - } - } else if (totp_code) { - const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret!, - encoding: 'base32', - token: totp_code, - window: 2 - }); - - if (!verified) { - return res.status(401).json({error: 'Invalid TOTP code'}); - } - } else { - return res.status(400).json({error: 'Authentication required'}); - } - - const backupCodes = Array.from({length: 8}, () => - Math.random().toString(36).substring(2, 10).toUpperCase() - ); - - await db.update(users) - .set({totp_backup_codes: JSON.stringify(backupCodes)}) - .where(eq(users.id, userId)); - - res.json({backup_codes: backupCodes}); - - } catch (err) { - authLogger.error('Failed to generate backup codes', err); - res.status(500).json({error: 'Failed to generate backup codes'}); + if (!userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is not enabled" }); } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({ error: "Incorrect password" }); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + } else { + return res.status(400).json({ error: "Authentication required" }); + } + + const backupCodes = Array.from({ length: 8 }, () => + Math.random().toString(36).substring(2, 10).toUpperCase(), + ); + + await db + .update(users) + .set({ totp_backup_codes: JSON.stringify(backupCodes) }) + .where(eq(users.id, userId)); + + res.json({ backup_codes: backupCodes }); + } catch (err) { + authLogger.error("Failed to generate backup codes", err); + res.status(500).json({ error: "Failed to generate backup codes" }); + } }); // Route: Delete user (admin only) // DELETE /users/delete-user -router.delete('/delete-user', authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - const {username} = req.body; +router.delete("/delete-user", authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { username } = req.body; - if (!isNonEmptyString(username)) { - return res.status(400).json({error: 'Username is required'}); + if (!isNonEmptyString(username)) { + return res.status(400).json({ error: "Username is required" }); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); } + if (adminUser[0].username === username) { + return res.status(400).json({ error: "Cannot delete your own account" }); + } + + const targetUser = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + if (targetUser[0].is_admin) { + const adminCount = db.$client + .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") + .get(); + if ((adminCount as any)?.count <= 1) { + return res + .status(403) + .json({ error: "Cannot delete the last admin user" }); + } + } + + const targetUserId = targetUser[0].id; + try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - return res.status(403).json({error: 'Not authorized'}); - } + await db + .delete(fileManagerRecent) + .where(eq(fileManagerRecent.userId, targetUserId)); + await db + .delete(fileManagerPinned) + .where(eq(fileManagerPinned.userId, targetUserId)); + await db + .delete(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.userId, targetUserId)); - if (adminUser[0].username === username) { - return res.status(400).json({error: 'Cannot delete your own account'}); - } + await db + .delete(dismissedAlerts) + .where(eq(dismissedAlerts.userId, targetUserId)); - const targetUser = await db.select().from(users).where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { - return res.status(404).json({error: 'User not found'}); - } - - if (targetUser[0].is_admin) { - const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); - if ((adminCount as any)?.count <= 1) { - return res.status(403).json({error: 'Cannot delete the last admin user'}); - } - } - - const targetUserId = targetUser[0].id; - - try { - await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId)); - await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId)); - await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId)); - - await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId)); - - await db.delete(sshData).where(eq(sshData.userId, targetUserId)); - } catch (cleanupError) { - authLogger.error(`Cleanup failed for user ${username}:`, cleanupError); - throw cleanupError; - } - - await db.delete(users).where(eq(users.id, targetUserId)); - - authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`); - res.json({message: `User ${username} deleted successfully`}); - - } catch (err) { - authLogger.error('Failed to delete user', err); - - if (err && typeof err === 'object' && 'code' in err) { - if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { - res.status(400).json({error: 'Cannot delete user: User has associated data that cannot be removed'}); - } else { - res.status(500).json({error: `Database error: ${err.code}`}); - } - } else { - res.status(500).json({error: 'Failed to delete account'}); - } + await db.delete(sshData).where(eq(sshData.userId, targetUserId)); + } catch (cleanupError) { + authLogger.error(`Cleanup failed for user ${username}:`, cleanupError); + throw cleanupError; } + + await db.delete(users).where(eq(users.id, targetUserId)); + + authLogger.success( + `User ${username} deleted by admin ${adminUser[0].username}`, + ); + res.json({ message: `User ${username} deleted successfully` }); + } catch (err) { + authLogger.error("Failed to delete user", err); + + if (err && typeof err === "object" && "code" in err) { + if (err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { + res + .status(400) + .json({ + error: + "Cannot delete user: User has associated data that cannot be removed", + }); + } else { + res.status(500).json({ error: `Database error: ${err.code}` }); + } + } else { + res.status(500).json({ error: "Failed to delete account" }); + } + } }); -export default router; \ No newline at end of file +export default router; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index fa96b095..b32a3fbd 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1,1172 +1,1285 @@ -import express from 'express'; -import cors from 'cors'; -import {Client as SSHClient} from 'ssh2'; -import {db} from '../database/db/index.js'; -import {sshCredentials} from '../database/db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import {fileLogger} from '../utils/logger.js'; +import express from "express"; +import cors from "cors"; +import { Client as SSHClient } from "ssh2"; +import { db } from "../database/db/index.js"; +import { sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { fileLogger } from "../utils/logger.js"; const app = express(); -app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); -app.use(express.json({limit: '100mb'})); -app.use(express.urlencoded({limit: '100mb', extended: true})); -app.use(express.raw({limit: '200mb', type: 'application/octet-stream'})); - +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + }), +); +app.use(express.json({ limit: "100mb" })); +app.use(express.urlencoded({ limit: "100mb", extended: true })); +app.use(express.raw({ limit: "200mb", type: "application/octet-stream" })); interface SSHSession { - client: SSHClient; - isConnected: boolean; - lastActive: number; - timeout?: NodeJS.Timeout; + client: SSHClient; + isConnected: boolean; + lastActive: number; + timeout?: NodeJS.Timeout; } const sshSessions: Record = {}; function cleanupSession(sessionId: string) { - const session = sshSessions[sessionId]; - if (session) { - try { - session.client.end(); - } catch { - } - clearTimeout(session.timeout); - delete sshSessions[sessionId]; - } + const session = sshSessions[sessionId]; + if (session) { + try { + session.client.end(); + } catch {} + clearTimeout(session.timeout); + delete sshSessions[sessionId]; + } } function scheduleSessionCleanup(sessionId: string) { - const session = sshSessions[sessionId]; - if (session) { - if (session.timeout) clearTimeout(session.timeout); - } + const session = sshSessions[sessionId]; + if (session) { + if (session.timeout) clearTimeout(session.timeout); + } } -app.post('/ssh/file_manager/ssh/connect', async (req, res) => { - const { +app.post("/ssh/file_manager/ssh/connect", async (req, res) => { + const { + sessionId, + hostId, + ip, + port, + username, + password, + sshKey, + keyPassword, + authType, + credentialId, + userId, + } = req.body; + + if (!sessionId || !ip || !username || !port) { + fileLogger.warn("Missing SSH connection parameters for file manager", { + operation: "file_connect", + sessionId, + hasIp: !!ip, + hasUsername: !!username, + hasPort: !!port, + }); + return res.status(400).json({ error: "Missing SSH connection parameters" }); + } + + if (sshSessions[sessionId]?.isConnected) { + cleanupSession(sessionId); + } + const client = new SSHClient(); + + let resolvedCredentials = { password, sshKey, keyPassword, authType }; + if (credentialId && hostId && userId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + authType: credential.authType, + }; + } else { + fileLogger.warn("No credentials found in database for file manager", { + operation: "file_connect", + sessionId, + hostId, + credentialId, + userId, + }); + } + } catch (error) { + fileLogger.warn( + "Failed to resolve credentials from database for file manager", + { + operation: "file_connect", + sessionId, + hostId, + credentialId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + } + } else if (credentialId && hostId) { + fileLogger.warn( + "Missing userId for credential resolution in file manager", + { + operation: "file_connect", sessionId, hostId, - ip, - port, - username, - password, - sshKey, - keyPassword, - authType, credentialId, - userId - } = req.body; + hasUserId: !!userId, + }, + ); + } - if (!sessionId || !ip || !username || !port) { - fileLogger.warn('Missing SSH connection parameters for file manager', { - operation: 'file_connect', - sessionId, - hasIp: !!ip, - hasUsername: !!username, - hasPort: !!port - }); - return res.status(400).json({error: 'Missing SSH connection parameters'}); + const config: any = { + host: ip, + port: port || 22, + username, + readyTimeout: 0, + keepaliveInterval: 30000, + keepaliveCountMax: 0, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + + if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) { + try { + if ( + !resolvedCredentials.sshKey.includes("-----BEGIN") || + !resolvedCredentials.sshKey.includes("-----END") + ) { + throw new Error("Invalid private key format"); + } + + const cleanKey = resolvedCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + config.privateKey = Buffer.from(cleanKey, "utf8"); + + if (resolvedCredentials.keyPassword) + config.passphrase = resolvedCredentials.keyPassword; + } catch (keyError) { + fileLogger.error("SSH key format error for file manager", { + operation: "file_connect", + sessionId, + hostId, + error: keyError.message, + }); + return res.status(400).json({ error: "Invalid SSH key format" }); } + } else if ( + resolvedCredentials.password && + resolvedCredentials.password.trim() + ) { + config.password = resolvedCredentials.password; + } else { + fileLogger.warn("No authentication method provided for file manager", { + operation: "file_connect", + sessionId, + hostId, + }); + return res + .status(400) + .json({ error: "Either password or SSH key must be provided" }); + } - if (sshSessions[sessionId]?.isConnected) { - cleanupSession(sessionId); - } - const client = new SSHClient(); + let responseSent = false; - let resolvedCredentials = {password, sshKey, keyPassword, authType}; - if (credentialId && hostId && userId) { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId) - )); - - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedCredentials = { - password: credential.password, - sshKey: credential.key, - keyPassword: credential.keyPassword, - authType: credential.authType - }; - } else { - fileLogger.warn('No credentials found in database for file manager', { - operation: 'file_connect', - sessionId, - hostId, - credentialId, - userId - }); - } - } catch (error) { - fileLogger.warn('Failed to resolve credentials from database for file manager', { - operation: 'file_connect', - sessionId, - hostId, - credentialId, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } else if (credentialId && hostId) { - fileLogger.warn('Missing userId for credential resolution in file manager', { - operation: 'file_connect', - sessionId, - hostId, - credentialId, - hasUserId: !!userId - }); - } - - const config: any = { - host: ip, - port: port || 22, - username, - readyTimeout: 0, - keepaliveInterval: 30000, - keepaliveCountMax: 0, - algorithms: { - kex: [ - 'diffie-hellman-group14-sha256', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521' - ], - cipher: [ - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm@openssh.com', - 'aes256-gcm@openssh.com', - 'aes128-cbc', - 'aes192-cbc', - 'aes256-cbc', - '3des-cbc' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - } + client.on("ready", () => { + if (responseSent) return; + responseSent = true; + sshSessions[sessionId] = { + client, + isConnected: true, + lastActive: Date.now(), }; + res.json({ status: "success", message: "SSH connection established" }); + }); - if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) { - try { - if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) { - throw new Error('Invalid private key format'); - } - - const cleanKey = resolvedCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - config.privateKey = Buffer.from(cleanKey, 'utf8'); - - if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; - - } catch (keyError) { - fileLogger.error('SSH key format error for file manager', { - operation: 'file_connect', - sessionId, - hostId, - error: keyError.message - }); - return res.status(400).json({error: 'Invalid SSH key format'}); - } - } else if (resolvedCredentials.password && resolvedCredentials.password.trim()) { - config.password = resolvedCredentials.password; - } else { - fileLogger.warn('No authentication method provided for file manager', { - operation: 'file_connect', - sessionId, - hostId - }); - return res.status(400).json({error: 'Either password or SSH key must be provided'}); - } - - let responseSent = false; - - client.on('ready', () => { - if (responseSent) return; - responseSent = true; - sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()}; - res.json({status: 'success', message: 'SSH connection established'}); + client.on("error", (err) => { + if (responseSent) return; + responseSent = true; + fileLogger.error("SSH connection failed for file manager", { + operation: "file_connect", + sessionId, + hostId, + ip, + port, + username, + error: err.message, }); + res.status(500).json({ status: "error", message: err.message }); + }); - client.on('error', (err) => { - if (responseSent) return; - responseSent = true; - fileLogger.error('SSH connection failed for file manager', { - operation: 'file_connect', - sessionId, - hostId, - ip, - port, - username, - error: err.message - }); - res.status(500).json({status: 'error', message: err.message}); - }); - - client.on('close', () => { - if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false; - cleanupSession(sessionId); - }); - - client.connect(config); -}); - -app.post('/ssh/file_manager/ssh/disconnect', (req, res) => { - const {sessionId} = req.body; + client.on("close", () => { + if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false; cleanupSession(sessionId); - res.json({status: 'success', message: 'SSH connection disconnected'}); + }); + + client.connect(config); }); -app.get('/ssh/file_manager/ssh/status', (req, res) => { - const sessionId = req.query.sessionId as string; - const isConnected = !!sshSessions[sessionId]?.isConnected; - res.json({status: 'success', connected: isConnected}); +app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { + const { sessionId } = req.body; + cleanupSession(sessionId); + res.json({ status: "success", message: "SSH connection disconnected" }); }); -app.get('/ssh/file_manager/ssh/listFiles', (req, res) => { - const sessionId = req.query.sessionId as string; - const sshConn = sshSessions[sessionId]; - const sshPath = decodeURIComponent((req.query.path as string) || '/'); +app.get("/ssh/file_manager/ssh/status", (req, res) => { + const sessionId = req.query.sessionId as string; + const isConnected = !!sshSessions[sessionId]?.isConnected; + res.json({ status: "success", connected: isConnected }); +}); - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); +app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const sshPath = decodeURIComponent((req.query.path as string) || "/"); + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + sshConn.lastActive = Date.now(); + + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); + sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { + if (err) { + fileLogger.error("SSH listFiles error:", err); + return res.status(500).json({ error: err.message }); } - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } + let data = ""; + let errorData = ""; - sshConn.lastActive = Date.now(); - - - const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { - if (err) { - fileLogger.error('SSH listFiles error:', err); - return res.status(500).json({error: err.message}); - } - - let data = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - data += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on('close', (code) => { - if (code !== 0) { - fileLogger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - return res.status(500).json({error: `Command failed: ${errorData}`}); - } - - const lines = data.split('\n').filter(line => line.trim()); - const files = []; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const parts = line.split(/\s+/); - if (parts.length >= 9) { - const permissions = parts[0]; - const name = parts.slice(8).join(' '); - const isDirectory = permissions.startsWith('d'); - const isLink = permissions.startsWith('l'); - - if (name === '.' || name === '..') continue; - - files.push({ - name, - type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') - }); - } - } - - res.json(files); - }); + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); }); -}); -app.get('/ssh/file_manager/ssh/readFile', (req, res) => { - const sessionId = req.query.sessionId as string; - const sshConn = sshSessions[sessionId]; - const filePath = decodeURIComponent(req.query.path as string); - - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); - } - - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } - - if (!filePath) { - return res.status(400).json({error: 'File path is required'}); - } - - sshConn.lastActive = Date.now(); - - - const escapedPath = filePath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { - if (err) { - fileLogger.error('SSH readFile error:', err); - return res.status(500).json({error: err.message}); - } - - let data = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - data += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on('close', (code) => { - if (code !== 0) { - fileLogger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - return res.status(500).json({error: `Command failed: ${errorData}`}); - } - - res.json({content: data, path: filePath}); - }); + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res.status(500).json({ error: `Command failed: ${errorData}` }); + } + + const lines = data.split("\n").filter((line) => line.trim()); + const files = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + if (parts.length >= 9) { + const permissions = parts[0]; + const name = parts.slice(8).join(" "); + const isDirectory = permissions.startsWith("d"); + const isLink = permissions.startsWith("l"); + + if (name === "." || name === "..") continue; + + files.push({ + name, + type: isDirectory ? "directory" : isLink ? "link" : "file", + }); + } + } + + res.json(files); + }); + }); }); -app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => { - const {sessionId, path: filePath, content, hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; +app.get("/ssh/file_manager/ssh/readFile", (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const filePath = decodeURIComponent(req.query.path as string); - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!filePath) { + return res.status(400).json({ error: "File path is required" }); + } + + sshConn.lastActive = Date.now(); + + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { + if (err) { + fileLogger.error("SSH readFile error:", err); + return res.status(500).json({ error: err.message }); } - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } + let data = ""; + let errorData = ""; - if (!filePath) { - return res.status(400).json({error: 'File path is required'}); - } + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); - if (content === undefined) { - return res.status(400).json({error: 'File content is required'}); - } + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); - sshConn.lastActive = Date.now(); + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res.status(500).json({ error: `Command failed: ${errorData}` }); + } - const trySFTP = () => { - try { - sshConn.client.sftp((err, sftp) => { - if (err) { - fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`); - tryFallbackMethod(); - return; - } + res.json({ content: data, path: filePath }); + }); + }); +}); - let fileBuffer; - try { - if (typeof content === 'string') { - fileBuffer = Buffer.from(content, 'utf8'); - } else if (Buffer.isBuffer(content)) { - fileBuffer = content; - } else { - fileBuffer = Buffer.from(content); - } - } catch (bufferErr) { - fileLogger.error('Buffer conversion error:', bufferErr); - if (!res.headersSent) { - return res.status(500).json({error: 'Invalid file content format'}); - } - return; - } +app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { + const { sessionId, path: filePath, content, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; - const writeStream = sftp.createWriteStream(filePath); + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } - let hasError = false; - let hasFinished = false; + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } - writeStream.on('error', (streamErr) => { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); - tryFallbackMethod(); - }); + if (!filePath) { + return res.status(400).json({ error: "File path is required" }); + } - writeStream.on('finish', () => { - if (hasError || hasFinished) return; - hasFinished = true; - if (!res.headersSent) { - res.json({ - message: 'File written successfully', - path: filePath, - toast: {type: 'success', message: `File written: ${filePath}`} - }); - } - }); + if (content === undefined) { + return res.status(400).json({ error: "File content is required" }); + } - writeStream.on('close', () => { - if (hasError || hasFinished) return; - hasFinished = true; - if (!res.headersSent) { - res.json({ - message: 'File written successfully', - path: filePath, - toast: {type: 'success', message: `File written: ${filePath}`} - }); - } - }); + sshConn.lastActive = Date.now(); - try { - writeStream.write(fileBuffer); - writeStream.end(); - } catch (writeErr) { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); - tryFallbackMethod(); - } - }); - } catch (sftpErr) { - fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); - tryFallbackMethod(); + const trySFTP = () => { + try { + sshConn.client.sftp((err, sftp) => { + if (err) { + fileLogger.warn( + `SFTP failed, trying fallback method: ${err.message}`, + ); + tryFallbackMethod(); + return; } - }; - const tryFallbackMethod = () => { + let fileBuffer; try { - const base64Content = Buffer.from(content, 'utf8').toString('base64'); - const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + if (typeof content === "string") { + fileBuffer = Buffer.from(content, "utf8"); + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + fileLogger.error("Buffer conversion error:", bufferErr); + if (!res.headersSent) { + return res + .status(500) + .json({ error: "Invalid file content format" }); + } + return; + } - const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + const writeStream = sftp.createWriteStream(filePath); - sshConn.client.exec(writeCommand, (err, stream) => { - if (err) { + let hasError = false; + let hasFinished = false; - fileLogger.error('Fallback write command failed:', err); - if (!res.headersSent) { - return res.status(500).json({ - error: `Write failed: ${err.message}`, - toast: {type: 'error', message: `Write failed: ${err.message}`} - }); - } - return; - } + writeStream.on("error", (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write failed, trying fallback method: ${streamErr.message}`, + ); + tryFallbackMethod(); + }); - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on('close', (code) => { - - - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'File written successfully', - path: filePath, - toast: {type: 'success', message: `File written: ${filePath}`} - }); - } - } else { - fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`); - if (!res.headersSent) { - res.status(500).json({ - error: `Write failed: ${errorData}`, - toast: {type: 'error', message: `Write failed: ${errorData}`} - }); - } - } - }); - - stream.on('error', (streamErr) => { - - fileLogger.error('Fallback write stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Write stream error: ${streamErr.message}`}); - } - }); + writeStream.on("finish", () => { + if (hasError || hasFinished) return; + hasFinished = true; + if (!res.headersSent) { + res.json({ + message: "File written successfully", + path: filePath, + toast: { type: "success", message: `File written: ${filePath}` }, }); - } catch (fallbackErr) { + } + }); - fileLogger.error('Fallback method failed:', fallbackErr); + writeStream.on("close", () => { + if (hasError || hasFinished) return; + hasFinished = true; + if (!res.headersSent) { + res.json({ + message: "File written successfully", + path: filePath, + toast: { type: "success", message: `File written: ${filePath}` }, + }); + } + }); + + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write operation failed, trying fallback method: ${writeErr.message}`, + ); + tryFallbackMethod(); + } + }); + } catch (sftpErr) { + fileLogger.warn( + `SFTP connection error, trying fallback method: ${sftpErr.message}`, + ); + tryFallbackMethod(); + } + }; + + const tryFallbackMethod = () => { + try { + const base64Content = Buffer.from(content, "utf8").toString("base64"); + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + + const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + + sshConn.client.exec(writeCommand, (err, stream) => { + if (err) { + fileLogger.error("Fallback write command failed:", err); + if (!res.headersSent) { + return res.status(500).json({ + error: `Write failed: ${err.message}`, + toast: { type: "error", message: `Write failed: ${err.message}` }, + }); + } + return; + } + + let outputData = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { if (!res.headersSent) { - res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`}); + res.json({ + message: "File written successfully", + path: filePath, + toast: { + type: "success", + message: `File written: ${filePath}`, + }, + }); } - } - }; + } else { + fileLogger.error( + `Fallback write failed with code ${code}: ${errorData}`, + ); + if (!res.headersSent) { + res.status(500).json({ + error: `Write failed: ${errorData}`, + toast: { type: "error", message: `Write failed: ${errorData}` }, + }); + } + } + }); - trySFTP(); + stream.on("error", (streamErr) => { + fileLogger.error("Fallback write stream error:", streamErr); + if (!res.headersSent) { + res + .status(500) + .json({ error: `Write stream error: ${streamErr.message}` }); + } + }); + }); + } catch (fallbackErr) { + fileLogger.error("Fallback method failed:", fallbackErr); + if (!res.headersSent) { + res + .status(500) + .json({ error: `All write methods failed: ${fallbackErr.message}` }); + } + } + }; + + trySFTP(); }); -app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => { - const {sessionId, path: filePath, content, fileName, hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; +app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { + const { + sessionId, + path: filePath, + content, + fileName, + hostId, + userId, + } = req.body; + const sshConn = sshSessions[sessionId]; - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); - } + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } - if (!filePath || !fileName || content === undefined) { - return res.status(400).json({error: 'File path, name, and content are required'}); - } + if (!filePath || !fileName || content === undefined) { + return res + .status(400) + .json({ error: "File path, name, and content are required" }); + } - sshConn.lastActive = Date.now(); + sshConn.lastActive = Date.now(); + const fullPath = filePath.endsWith("/") + ? filePath + fileName + : filePath + "/" + fileName; - const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; - - - const trySFTP = () => { - try { - sshConn.client.sftp((err, sftp) => { - if (err) { - fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`); - tryFallbackMethod(); - return; - } - - let fileBuffer; - try { - if (typeof content === 'string') { - fileBuffer = Buffer.from(content, 'utf8'); - } else if (Buffer.isBuffer(content)) { - fileBuffer = content; - } else { - fileBuffer = Buffer.from(content); - } - } catch (bufferErr) { - - fileLogger.error('Buffer conversion error:', bufferErr); - if (!res.headersSent) { - return res.status(500).json({error: 'Invalid file content format'}); - } - return; - } - - const writeStream = sftp.createWriteStream(fullPath); - - let hasError = false; - let hasFinished = false; - - writeStream.on('error', (streamErr) => { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); - tryFallbackMethod(); - }); - - writeStream.on('finish', () => { - if (hasError || hasFinished) return; - hasFinished = true; - if (!res.headersSent) { - res.json({ - message: 'File uploaded successfully', - path: fullPath, - toast: {type: 'success', message: `File uploaded: ${fullPath}`} - }); - } - }); - - writeStream.on('close', () => { - if (hasError || hasFinished) return; - hasFinished = true; - if (!res.headersSent) { - res.json({ - message: 'File uploaded successfully', - path: fullPath, - toast: {type: 'success', message: `File uploaded: ${fullPath}`} - }); - } - }); - - try { - writeStream.write(fileBuffer); - writeStream.end(); - } catch (writeErr) { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); - tryFallbackMethod(); - } - }); - } catch (sftpErr) { - fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); - tryFallbackMethod(); + const trySFTP = () => { + try { + sshConn.client.sftp((err, sftp) => { + if (err) { + fileLogger.warn( + `SFTP failed, trying fallback method: ${err.message}`, + ); + tryFallbackMethod(); + return; } - }; - const tryFallbackMethod = () => { + let fileBuffer; try { - const base64Content = Buffer.from(content, 'utf8').toString('base64'); - const chunkSize = 1000000; - const chunks = []; + if (typeof content === "string") { + fileBuffer = Buffer.from(content, "utf8"); + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + fileLogger.error("Buffer conversion error:", bufferErr); + if (!res.headersSent) { + return res + .status(500) + .json({ error: "Invalid file content format" }); + } + return; + } - for (let i = 0; i < base64Content.length; i += chunkSize) { - chunks.push(base64Content.slice(i, i + chunkSize)); + const writeStream = sftp.createWriteStream(fullPath); + + let hasError = false; + let hasFinished = false; + + writeStream.on("error", (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write failed, trying fallback method: ${streamErr.message}`, + ); + tryFallbackMethod(); + }); + + writeStream.on("finish", () => { + if (hasError || hasFinished) return; + hasFinished = true; + if (!res.headersSent) { + res.json({ + message: "File uploaded successfully", + path: fullPath, + toast: { type: "success", message: `File uploaded: ${fullPath}` }, + }); + } + }); + + writeStream.on("close", () => { + if (hasError || hasFinished) return; + hasFinished = true; + if (!res.headersSent) { + res.json({ + message: "File uploaded successfully", + path: fullPath, + toast: { type: "success", message: `File uploaded: ${fullPath}` }, + }); + } + }); + + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write operation failed, trying fallback method: ${writeErr.message}`, + ); + tryFallbackMethod(); + } + }); + } catch (sftpErr) { + fileLogger.warn( + `SFTP connection error, trying fallback method: ${sftpErr.message}`, + ); + tryFallbackMethod(); + } + }; + + const tryFallbackMethod = () => { + try { + const base64Content = Buffer.from(content, "utf8").toString("base64"); + const chunkSize = 1000000; + const chunks = []; + + for (let i = 0; i < base64Content.length; i += chunkSize) { + chunks.push(base64Content.slice(i, i + chunkSize)); + } + + if (chunks.length === 1) { + const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + + sshConn.client.exec(writeCommand, (err, stream) => { + if (err) { + fileLogger.error("Fallback upload command failed:", err); + if (!res.headersSent) { + return res + .status(500) + .json({ error: `Upload failed: ${err.message}` }); } + return; + } - if (chunks.length === 1) { - const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); - const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + let outputData = ""; + let errorData = ""; - const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); - sshConn.client.exec(writeCommand, (err, stream) => { - if (err) { + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); - fileLogger.error('Fallback upload command failed:', err); - if (!res.headersSent) { - return res.status(500).json({error: `Upload failed: ${err.message}`}); - } - return; - } - - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on('close', (code) => { - - - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'File uploaded successfully', - path: fullPath, - toast: {type: 'success', message: `File uploaded: ${fullPath}`} - }); - } - } else { - fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`); - if (!res.headersSent) { - res.status(500).json({ - error: `Upload failed: ${errorData}`, - toast: {type: 'error', message: `Upload failed: ${errorData}`} - }); - } - } - }); - - stream.on('error', (streamErr) => { - - fileLogger.error('Fallback upload stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Upload stream error: ${streamErr.message}`}); - } - }); + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "File uploaded successfully", + path: fullPath, + toast: { + type: "success", + message: `File uploaded: ${fullPath}`, + }, }); + } } else { - const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); - const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - - let writeCommand = `> '${escapedPath}'`; - - chunks.forEach((chunk, index) => { - writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`; - }); - - writeCommand += ` && echo "SUCCESS"`; - - sshConn.client.exec(writeCommand, (err, stream) => { - if (err) { - - fileLogger.error('Chunked fallback upload failed:', err); - if (!res.headersSent) { - return res.status(500).json({error: `Chunked upload failed: ${err.message}`}); - } - return; - } - - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on('close', (code) => { - - - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'File uploaded successfully', - path: fullPath, - toast: {type: 'success', message: `File uploaded: ${fullPath}`} - }); - } - } else { - fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); - if (!res.headersSent) { - res.status(500).json({ - error: `Chunked upload failed: ${errorData}`, - toast: {type: 'error', message: `Chunked upload failed: ${errorData}`} - }); - } - } - }); - - stream.on('error', (streamErr) => { - fileLogger.error('Chunked fallback upload stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`}); - } - }); + fileLogger.error( + `Fallback upload failed with code ${code}: ${errorData}`, + ); + if (!res.headersSent) { + res.status(500).json({ + error: `Upload failed: ${errorData}`, + toast: { + type: "error", + message: `Upload failed: ${errorData}`, + }, }); + } } - } catch (fallbackErr) { - fileLogger.error('Fallback method failed:', fallbackErr); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("Fallback upload stream error:", streamErr); if (!res.headersSent) { - res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`}); + res + .status(500) + .json({ error: `Upload stream error: ${streamErr.message}` }); } - } - }; + }); + }); + } else { + const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - trySFTP(); -}); + let writeCommand = `> '${escapedPath}'`; -app.post('/ssh/file_manager/ssh/createFile', async (req, res) => { - const {sessionId, path: filePath, fileName, content = '', hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; + chunks.forEach((chunk, index) => { + writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`; + }); - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); - } + writeCommand += ` && echo "SUCCESS"`; - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } - - if (!filePath || !fileName) { - return res.status(400).json({error: 'File path and name are required'}); - } - - sshConn.lastActive = Date.now(); - - const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; - const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - - const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; - - sshConn.client.exec(createCommand, (err, stream) => { - if (err) { - fileLogger.error('SSH createFile error:', err); + sshConn.client.exec(writeCommand, (err, stream) => { + if (err) { + fileLogger.error("Chunked fallback upload failed:", err); if (!res.headersSent) { - return res.status(500).json({error: err.message}); + return res + .status(500) + .json({ error: `Chunked upload failed: ${err.message}` }); } return; - } + } - let outputData = ''; - let errorData = ''; + let outputData = ""; + let errorData = ""; - stream.on('data', (chunk: Buffer) => { + stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); - }); + }); - stream.stderr.on('data', (chunk: Buffer) => { + stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); + }); - if (chunk.toString().includes('Permission denied')) { - fileLogger.error(`Permission denied creating file: ${fullPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.` - }); - } - return; - } - }); - - stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'File created successfully', - path: fullPath, - toast: {type: 'success', message: `File created: ${fullPath}`} - }); - } - return; - } - - if (code !== 0) { - fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: {type: 'error', message: `File creation failed: ${errorData}`} - }); - } - return; - } - - if (!res.headersSent) { + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { res.json({ - message: 'File created successfully', - path: fullPath, - toast: {type: 'success', message: `File created: ${fullPath}`} + message: "File uploaded successfully", + path: fullPath, + toast: { + type: "success", + message: `File uploaded: ${fullPath}`, + }, + }); + } + } else { + fileLogger.error( + `Chunked fallback upload failed with code ${code}: ${errorData}`, + ); + if (!res.headersSent) { + res.status(500).json({ + error: `Chunked upload failed: ${errorData}`, + toast: { + type: "error", + message: `Chunked upload failed: ${errorData}`, + }, + }); + } + } + }); + + stream.on("error", (streamErr) => { + fileLogger.error( + "Chunked fallback upload stream error:", + streamErr, + ); + if (!res.headersSent) { + res + .status(500) + .json({ + error: `Chunked upload stream error: ${streamErr.message}`, }); } + }); }); + } + } catch (fallbackErr) { + fileLogger.error("Fallback method failed:", fallbackErr); + if (!res.headersSent) { + res + .status(500) + .json({ error: `All upload methods failed: ${fallbackErr.message}` }); + } + } + }; - stream.on('error', (streamErr) => { - fileLogger.error('SSH createFile stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Stream error: ${streamErr.message}`}); - } - }); - }); + trySFTP(); }); -app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => { - const {sessionId, path: folderPath, folderName, hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; +app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { + const { + sessionId, + path: filePath, + fileName, + content = "", + hostId, + userId, + } = req.body; + const sshConn = sshSessions[sessionId]; - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!filePath || !fileName) { + return res.status(400).json({ error: "File path and name are required" }); + } + + sshConn.lastActive = Date.now(); + + const fullPath = filePath.endsWith("/") + ? filePath + fileName + : filePath + "/" + fileName; + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(createCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH createFile error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; } - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } + let outputData = ""; + let errorData = ""; - if (!folderPath || !folderName) { - return res.status(400).json({error: 'Folder path and name are required'}); - } + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); - sshConn.lastActive = Date.now(); + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); - const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName; - const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - - const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; - - sshConn.client.exec(createCommand, (err, stream) => { - if (err) { - - fileLogger.error('SSH createFolder error:', err); - if (!res.headersSent) { - return res.status(500).json({error: err.message}); - } - return; + if (chunk.toString().includes("Permission denied")) { + fileLogger.error(`Permission denied creating file: ${fullPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`, + }); } - - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - - if (chunk.toString().includes('Permission denied')) { - fileLogger.error(`Permission denied creating folder: ${fullPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.` - }); - } - return; - } - }); - - stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'Folder created successfully', - path: fullPath, - toast: {type: 'success', message: `Folder created: ${fullPath}`} - }); - } - return; - } - - if (code !== 0) { - fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: {type: 'error', message: `Folder creation failed: ${errorData}`} - }); - } - return; - } - - if (!res.headersSent) { - res.json({ - message: 'Folder created successfully', - path: fullPath, - toast: {type: 'success', message: `Folder created: ${fullPath}`} - }); - } - }); - - stream.on('error', (streamErr) => { - fileLogger.error('SSH createFolder stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Stream error: ${streamErr.message}`}); - } - }); + return; + } }); -}); -app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => { - const {sessionId, path: itemPath, isDirectory, hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; - - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); - } - - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } - - if (!itemPath) { - return res.status(400).json({error: 'Item path is required'}); - } - - sshConn.lastActive = Date.now(); - const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); - - const deleteCommand = isDirectory - ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` - : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; - - sshConn.client.exec(deleteCommand, (err, stream) => { - if (err) { - fileLogger.error('SSH deleteItem error:', err); - if (!res.headersSent) { - return res.status(500).json({error: err.message}); - } - return; + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "File created successfully", + path: fullPath, + toast: { type: "success", message: `File created: ${fullPath}` }, + }); } + return; + } - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - - if (chunk.toString().includes('Permission denied')) { - fileLogger.error(`Permission denied deleting: ${itemPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.` - }); - } - return; - } - }); - - stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'Item deleted successfully', - path: itemPath, - toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`} - }); - } - return; - } - - if (code !== 0) { - fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: {type: 'error', message: `Delete failed: ${errorData}`} - }); - } - return; - } - - if (!res.headersSent) { - res.json({ - message: 'Item deleted successfully', - path: itemPath, - toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`} - }); - } - }); - - stream.on('error', (streamErr) => { - fileLogger.error('SSH deleteItem stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Stream error: ${streamErr.message}`}); - } - }); - }); -}); - -app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => { - const {sessionId, oldPath, newName, hostId, userId} = req.body; - const sshConn = sshSessions[sessionId]; - - if (!sessionId) { - return res.status(400).json({error: 'Session ID is required'}); - } - - if (!sshConn?.isConnected) { - return res.status(400).json({error: 'SSH connection not established'}); - } - - if (!oldPath || !newName) { - return res.status(400).json({error: 'Old path and new name are required'}); - } - - sshConn.lastActive = Date.now(); - - const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1); - const newPath = oldDir + newName; - const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); - const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); - - const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; - - sshConn.client.exec(renameCommand, (err, stream) => { - if (err) { - fileLogger.error('SSH renameItem error:', err); - if (!res.headersSent) { - return res.status(500).json({error: err.message}); - } - return; + if (code !== 0) { + fileLogger.error( + `SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + if (!res.headersSent) { + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: { + type: "error", + message: `File creation failed: ${errorData}`, + }, + }); } + return; + } - let outputData = ''; - let errorData = ''; - - stream.on('data', (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on('data', (chunk: Buffer) => { - errorData += chunk.toString(); - - if (chunk.toString().includes('Permission denied')) { - fileLogger.error(`Permission denied renaming: ${oldPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.` - }); - } - return; - } - }); - - stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({ - message: 'Item renamed successfully', - oldPath, - newPath, - toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`} - }); - } - return; - } - - if (code !== 0) { - fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: {type: 'error', message: `Rename failed: ${errorData}`} - }); - } - return; - } - - if (!res.headersSent) { - res.json({ - message: 'Item renamed successfully', - oldPath, - newPath, - toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`} - }); - } - }); - - stream.on('error', (streamErr) => { - fileLogger.error('SSH renameItem stream error:', streamErr); - if (!res.headersSent) { - res.status(500).json({error: `Stream error: ${streamErr.message}`}); - } + if (!res.headersSent) { + res.json({ + message: "File created successfully", + path: fullPath, + toast: { type: "success", message: `File created: ${fullPath}` }, }); + } }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH createFile stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); }); -process.on('SIGINT', () => { - Object.keys(sshSessions).forEach(cleanupSession); - process.exit(0); +app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { + const { sessionId, path: folderPath, folderName, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!folderPath || !folderName) { + return res.status(400).json({ error: "Folder path and name are required" }); + } + + sshConn.lastActive = Date.now(); + + const fullPath = folderPath.endsWith("/") + ? folderPath + folderName + : folderPath + "/" + folderName; + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(createCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH createFolder error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let outputData = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes("Permission denied")) { + fileLogger.error(`Permission denied creating folder: ${fullPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`, + }); + } + return; + } + }); + + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "Folder created successfully", + path: fullPath, + toast: { type: "success", message: `Folder created: ${fullPath}` }, + }); + } + return; + } + + if (code !== 0) { + fileLogger.error( + `SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + if (!res.headersSent) { + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: { + type: "error", + message: `Folder creation failed: ${errorData}`, + }, + }); + } + return; + } + + if (!res.headersSent) { + res.json({ + message: "Folder created successfully", + path: fullPath, + toast: { type: "success", message: `Folder created: ${fullPath}` }, + }); + } + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH createFolder stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); }); -process.on('SIGTERM', () => { - Object.keys(sshSessions).forEach(cleanupSession); - process.exit(0); +app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { + const { sessionId, path: itemPath, isDirectory, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!itemPath) { + return res.status(400).json({ error: "Item path is required" }); + } + + sshConn.lastActive = Date.now(); + const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); + + const deleteCommand = isDirectory + ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` + : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(deleteCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH deleteItem error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let outputData = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes("Permission denied")) { + fileLogger.error(`Permission denied deleting: ${itemPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`, + }); + } + return; + } + }); + + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } + return; + } + + if (code !== 0) { + fileLogger.error( + `SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + if (!res.headersSent) { + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: { type: "error", message: `Delete failed: ${errorData}` }, + }); + } + return; + } + + if (!res.headersSent) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH deleteItem stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); +}); + +app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { + const { sessionId, oldPath, newName, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!oldPath || !newName) { + return res + .status(400) + .json({ error: "Old path and new name are required" }); + } + + sshConn.lastActive = Date.now(); + + const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1); + const newPath = oldDir + newName; + const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); + const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); + + const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(renameCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH renameItem error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let outputData = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes("Permission denied")) { + fileLogger.error(`Permission denied renaming: ${oldPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`, + }); + } + return; + } + }); + + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "Item renamed successfully", + oldPath, + newPath, + toast: { + type: "success", + message: `Item renamed: ${oldPath} -> ${newPath}`, + }, + }); + } + return; + } + + if (code !== 0) { + fileLogger.error( + `SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + if (!res.headersSent) { + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: { type: "error", message: `Rename failed: ${errorData}` }, + }); + } + return; + } + + if (!res.headersSent) { + res.json({ + message: "Item renamed successfully", + oldPath, + newPath, + toast: { + type: "success", + message: `Item renamed: ${oldPath} -> ${newPath}`, + }, + }); + } + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH renameItem stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); +}); + +process.on("SIGINT", () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); +}); + +process.on("SIGTERM", () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); }); const PORT = 8084; app.listen(PORT, () => { - fileLogger.success('File Manager API server started', {operation: 'server_start', port: PORT}); -}); \ No newline at end of file + fileLogger.success("File Manager API server started", { + operation: "server_start", + port: PORT, + }); +}); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 4990ac8d..c36a3d25 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -1,783 +1,901 @@ -import express from 'express'; -import net from 'net'; -import cors from 'cors'; -import {Client, type ConnectConfig} from 'ssh2'; -import {db} from '../database/db/index.js'; -import {sshData, sshCredentials} from '../database/db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import {statsLogger} from '../utils/logger.js'; +import express from "express"; +import net from "net"; +import cors from "cors"; +import { Client, type ConnectConfig } from "ssh2"; +import { db } from "../database/db/index.js"; +import { sshData, sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { statsLogger } from "../utils/logger.js"; interface PooledConnection { - client: Client; - lastUsed: number; - inUse: boolean; - hostKey: string; + client: Client; + lastUsed: number; + inUse: boolean; + hostKey: string; } class SSHConnectionPool { - private connections = new Map(); - private maxConnectionsPerHost = 3; - private connectionTimeout = 30000; - private cleanupInterval: NodeJS.Timeout; + private connections = new Map(); + private maxConnectionsPerHost = 3; + private connectionTimeout = 30000; + private cleanupInterval: NodeJS.Timeout; - constructor() { - this.cleanupInterval = setInterval(() => { - this.cleanup(); - }, 5 * 60 * 1000); + constructor() { + this.cleanupInterval = setInterval( + () => { + this.cleanup(); + }, + 5 * 60 * 1000, + ); + } + + private getHostKey(host: SSHHostWithCredentials): string { + return `${host.ip}:${host.port}:${host.username}`; + } + + async getConnection(host: SSHHostWithCredentials): Promise { + const hostKey = this.getHostKey(host); + const connections = this.connections.get(hostKey) || []; + + const available = connections.find((conn) => !conn.inUse); + if (available) { + available.inUse = true; + available.lastUsed = Date.now(); + return available.client; } - private getHostKey(host: SSHHostWithCredentials): string { - return `${host.ip}:${host.port}:${host.username}`; + if (connections.length < this.maxConnectionsPerHost) { + const client = await this.createConnection(host); + const pooled: PooledConnection = { + client, + lastUsed: Date.now(), + inUse: true, + hostKey, + }; + connections.push(pooled); + this.connections.set(hostKey, connections); + return client; } - async getConnection(host: SSHHostWithCredentials): Promise { - const hostKey = this.getHostKey(host); - const connections = this.connections.get(hostKey) || []; - - const available = connections.find(conn => !conn.inUse); + return new Promise((resolve, reject) => { + const checkAvailable = () => { + const available = connections.find((conn) => !conn.inUse); if (available) { - available.inUse = true; - available.lastUsed = Date.now(); - return available.client; + available.inUse = true; + available.lastUsed = Date.now(); + resolve(available.client); + } else { + setTimeout(checkAvailable, 100); } + }; + checkAvailable(); + }); + } - if (connections.length < this.maxConnectionsPerHost) { - const client = await this.createConnection(host); - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey - }; - connections.push(pooled); - this.connections.set(hostKey, connections); - return client; + private async createConnection( + host: SSHHostWithCredentials, + ): Promise { + return new Promise((resolve, reject) => { + const client = new Client(); + const timeout = setTimeout(() => { + client.end(); + reject(new Error("SSH connection timeout")); + }, this.connectionTimeout); + + client.on("ready", () => { + clearTimeout(timeout); + resolve(client); + }); + + client.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + try { + client.connect(buildSshConfig(host)); + } catch (err) { + clearTimeout(timeout); + reject(err); + } + }); + } + + releaseConnection(host: SSHHostWithCredentials, client: Client): void { + const hostKey = this.getHostKey(host); + const connections = this.connections.get(hostKey) || []; + const pooled = connections.find((conn) => conn.client === client); + if (pooled) { + pooled.inUse = false; + pooled.lastUsed = Date.now(); + } + } + + private cleanup(): void { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; + + for (const [hostKey, connections] of this.connections.entries()) { + const activeConnections = connections.filter((conn) => { + if (!conn.inUse && now - conn.lastUsed > maxAge) { + try { + conn.client.end(); + } catch {} + return false; } + return true; + }); - return new Promise((resolve, reject) => { - const checkAvailable = () => { - const available = connections.find(conn => !conn.inUse); - if (available) { - available.inUse = true; - available.lastUsed = Date.now(); - resolve(available.client); - } else { - setTimeout(checkAvailable, 100); - } - }; - checkAvailable(); - }); + if (activeConnections.length === 0) { + this.connections.delete(hostKey); + } else { + this.connections.set(hostKey, activeConnections); + } } + } - private async createConnection(host: SSHHostWithCredentials): Promise { - return new Promise((resolve, reject) => { - const client = new Client(); - const timeout = setTimeout(() => { - client.end(); - reject(new Error('SSH connection timeout')); - }, this.connectionTimeout); - - client.on('ready', () => { - clearTimeout(timeout); - resolve(client); - }); - - client.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - - try { - client.connect(buildSshConfig(host)); - } catch (err) { - clearTimeout(timeout); - reject(err); - } - }); - } - - releaseConnection(host: SSHHostWithCredentials, client: Client): void { - const hostKey = this.getHostKey(host); - const connections = this.connections.get(hostKey) || []; - const pooled = connections.find(conn => conn.client === client); - if (pooled) { - pooled.inUse = false; - pooled.lastUsed = Date.now(); - } - } - - private cleanup(): void { - const now = Date.now(); - const maxAge = 10 * 60 * 1000; - - for (const [hostKey, connections] of this.connections.entries()) { - const activeConnections = connections.filter(conn => { - if (!conn.inUse && (now - conn.lastUsed) > maxAge) { - try { - conn.client.end(); - } catch { - - } - return false; - } - return true; - }); - - if (activeConnections.length === 0) { - this.connections.delete(hostKey); - } else { - this.connections.set(hostKey, activeConnections); - } - } - } - - destroy(): void { - clearInterval(this.cleanupInterval); - for (const connections of this.connections.values()) { - for (const conn of connections) { - try { - conn.client.end(); - } catch { - - } - } - } - this.connections.clear(); + destroy(): void { + clearInterval(this.cleanupInterval); + for (const connections of this.connections.values()) { + for (const conn of connections) { + try { + conn.client.end(); + } catch {} + } } + this.connections.clear(); + } } class RequestQueue { - private queues = new Map Promise>>(); - private processing = new Set(); + private queues = new Map Promise>>(); + private processing = new Set(); - async queueRequest(hostId: number, request: () => Promise): Promise { - return new Promise((resolve, reject) => { - const queue = this.queues.get(hostId) || []; - queue.push(async () => { - try { - const result = await request(); - resolve(result); - } catch (error) { - reject(error); - } - }); - this.queues.set(hostId, queue); - this.processQueue(hostId); - }); + async queueRequest(hostId: number, request: () => Promise): Promise { + return new Promise((resolve, reject) => { + const queue = this.queues.get(hostId) || []; + queue.push(async () => { + try { + const result = await request(); + resolve(result); + } catch (error) { + reject(error); + } + }); + this.queues.set(hostId, queue); + this.processQueue(hostId); + }); + } + + private async processQueue(hostId: number): Promise { + if (this.processing.has(hostId)) return; + + this.processing.add(hostId); + const queue = this.queues.get(hostId) || []; + + while (queue.length > 0) { + const request = queue.shift(); + if (request) { + try { + await request(); + } catch (error) {} + } } - private async processQueue(hostId: number): Promise { - if (this.processing.has(hostId)) return; - - this.processing.add(hostId); - const queue = this.queues.get(hostId) || []; - - while (queue.length > 0) { - const request = queue.shift(); - if (request) { - try { - await request(); - } catch (error) { - - } - } - } - - this.processing.delete(hostId); - if (queue.length > 0) { - this.processQueue(hostId); - } + this.processing.delete(hostId); + if (queue.length > 0) { + this.processQueue(hostId); } + } } interface CachedMetrics { - data: any; - timestamp: number; - hostId: number; + data: any; + timestamp: number; + hostId: number; } class MetricsCache { - private cache = new Map(); - private ttl = 30000; + private cache = new Map(); + private ttl = 30000; - get(hostId: number): any | null { - const cached = this.cache.get(hostId); - if (cached && (Date.now() - cached.timestamp) < this.ttl) { - return cached.data; - } - return null; + get(hostId: number): any | null { + const cached = this.cache.get(hostId); + if (cached && Date.now() - cached.timestamp < this.ttl) { + return cached.data; } + return null; + } - set(hostId: number, data: any): void { - this.cache.set(hostId, { - data, - timestamp: Date.now(), - hostId - }); - } + set(hostId: number, data: any): void { + this.cache.set(hostId, { + data, + timestamp: Date.now(), + hostId, + }); + } - clear(hostId?: number): void { - if (hostId) { - this.cache.delete(hostId); - } else { - this.cache.clear(); - } + clear(hostId?: number): void { + if (hostId) { + this.cache.delete(hostId); + } else { + this.cache.clear(); } + } } const connectionPool = new SSHConnectionPool(); const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); -type HostStatus = 'online' | 'offline'; +type HostStatus = "online" | "offline"; interface SSHHostWithCredentials { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; - userId: string; + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; + userId: string; } type StatusEntry = { - status: HostStatus; - lastChecked: string; + status: HostStatus; + lastChecked: string; }; -function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) { - const id = Number(req.params.id); - if (!id || !Number.isInteger(id) || id <= 0) { - return res.status(400).json({error: 'Invalid host ID'}); - } - next(); +function validateHostId( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) { + const id = Number(req.params.id); + if (!id || !Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: "Invalid host ID" }); + } + next(); } const app = express(); -app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + }), +); app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - if (req.method === 'OPTIONS') { - return res.sendStatus(204); - } - next(); + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.header( + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, OPTIONS", + ); + if (req.method === "OPTIONS") { + return res.sendStatus(204); + } + next(); }); -app.use(express.json({limit: '1mb'})); - +app.use(express.json({ limit: "1mb" })); const hostStatuses: Map = new Map(); async function fetchAllHosts(): Promise { - try { - const hosts = await db.select().from(sshData); + try { + const hosts = await db.select().from(sshData); - const hostsWithCredentials: SSHHostWithCredentials[] = []; - for (const host of hosts) { - try { - const hostWithCreds = await resolveHostCredentials(host); - if (hostWithCreds) { - hostsWithCredentials.push(hostWithCreds); - } - } catch (err) { - statsLogger.warn(`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : 'Unknown error'}`); - } + const hostsWithCredentials: SSHHostWithCredentials[] = []; + for (const host of hosts) { + try { + const hostWithCreds = await resolveHostCredentials(host); + if (hostWithCreds) { + hostsWithCredentials.push(hostWithCreds); } - - return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port); - } catch (err) { - statsLogger.error('Failed to fetch hosts from database', err); - return []; + } catch (err) { + statsLogger.warn( + `Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } } + + return hostsWithCredentials.filter((h) => !!h.id && !!h.ip && !!h.port); + } catch (err) { + statsLogger.error("Failed to fetch hosts from database", err); + return []; + } } -async function fetchHostById(id: number): Promise { - try { - const hosts = await db.select().from(sshData).where(eq(sshData.id, id)); +async function fetchHostById( + id: number, +): Promise { + try { + const hosts = await db.select().from(sshData).where(eq(sshData.id, id)); - if (hosts.length === 0) { - return undefined; - } - - const host = hosts[0]; - return await resolveHostCredentials(host); - } catch (err) { - statsLogger.error(`Failed to fetch host ${id}`, err); - return undefined; + if (hosts.length === 0) { + return undefined; } + + const host = hosts[0]; + return await resolveHostCredentials(host); + } catch (err) { + statsLogger.error(`Failed to fetch host ${id}`, err); + return undefined; + } } -async function resolveHostCredentials(host: any): Promise { - try { - const baseHost: any = { - id: host.id, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder || '', - tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [], - pin: !!host.pin, - authType: host.authType, - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - enableFileManager: !!host.enableFileManager, - defaultPath: host.defaultPath || '/', - tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], - createdAt: host.createdAt, - updatedAt: host.updatedAt, - userId: host.userId - }; +async function resolveHostCredentials( + host: any, +): Promise { + try { + const baseHost: any = { + id: host.id, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder || "", + tags: + typeof host.tags === "string" + ? host.tags + ? host.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!host.pin, + authType: host.authType, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + enableFileManager: !!host.enableFileManager, + defaultPath: host.defaultPath || "/", + tunnelConnections: host.tunnelConnections + ? JSON.parse(host.tunnelConnections) + : [], + createdAt: host.createdAt, + updatedAt: host.updatedAt, + userId: host.userId, + }; - if (host.credentialId) { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, host.credentialId), - eq(sshCredentials.userId, host.userId) - )); + if (host.credentialId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId), + ), + ); - if (credentials.length > 0) { - const credential = credentials[0]; - baseHost.credentialId = credential.id; - baseHost.username = credential.username; - baseHost.authType = credential.authType; + if (credentials.length > 0) { + const credential = credentials[0]; + baseHost.credentialId = credential.id; + baseHost.username = credential.username; + baseHost.authType = credential.authType; - if (credential.password) { - baseHost.password = credential.password; - } - if (credential.key) { - baseHost.key = credential.key; - } - if (credential.keyPassword) { - baseHost.keyPassword = credential.keyPassword; - } - if (credential.keyType) { - baseHost.keyType = credential.keyType; - } - - } else { - statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); - addLegacyCredentials(baseHost, host); - } - } catch (error) { - statsLogger.warn(`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); - addLegacyCredentials(baseHost, host); - } + if (credential.password) { + baseHost.password = credential.password; + } + if (credential.key) { + baseHost.key = credential.key; + } + if (credential.keyPassword) { + baseHost.keyPassword = credential.keyPassword; + } + if (credential.keyType) { + baseHost.keyType = credential.keyType; + } } else { - addLegacyCredentials(baseHost, host); + statsLogger.warn( + `Credential ${host.credentialId} not found for host ${host.id}, using legacy data`, + ); + addLegacyCredentials(baseHost, host); } - - return baseHost; - } catch (error) { - statsLogger.error(`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); - return undefined; + } catch (error) { + statsLogger.warn( + `Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + addLegacyCredentials(baseHost, host); + } + } else { + addLegacyCredentials(baseHost, host); } + + return baseHost; + } catch (error) { + statsLogger.error( + `Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return undefined; + } } function addLegacyCredentials(baseHost: any, host: any): void { - baseHost.password = host.password || null; - baseHost.key = host.key || null; - baseHost.keyPassword = host.keyPassword || null; - baseHost.keyType = host.keyType; + baseHost.password = host.password || null; + baseHost.key = host.key || null; + baseHost.keyPassword = host.keyPassword || null; + baseHost.keyType = host.keyType; } function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { - const base: ConnectConfig = { - host: host.ip, - port: host.port || 22, - username: host.username || 'root', - readyTimeout: 10_000, - algorithms: {} - } as ConnectConfig; + const base: ConnectConfig = { + host: host.ip, + port: host.port || 22, + username: host.username || "root", + readyTimeout: 10_000, + algorithms: {}, + } as ConnectConfig; - if (host.authType === 'password') { - if (!host.password) { - throw new Error(`No password available for host ${host.ip}`); - } - (base as any).password = host.password; - } else if (host.authType === 'key') { - if (!host.key) { - throw new Error(`No SSH key available for host ${host.ip}`); - } - - try { - if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) { - throw new Error('Invalid private key format'); - } - - const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - (base as any).privateKey = Buffer.from(cleanKey, 'utf8'); - - if (host.keyPassword) { - (base as any).passphrase = host.keyPassword; - } - } catch (keyError) { - statsLogger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`); - throw new Error(`Invalid SSH key format for host ${host.ip}`); - } - } else { - throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`); + if (host.authType === "password") { + if (!host.password) { + throw new Error(`No password available for host ${host.ip}`); + } + (base as any).password = host.password; + } else if (host.authType === "key") { + if (!host.key) { + throw new Error(`No SSH key available for host ${host.ip}`); } - return base; -} - -async function withSshConnection(host: SSHHostWithCredentials, fn: (client: Client) => Promise): Promise { - const client = await connectionPool.getConnection(host); try { - const result = await fn(client); - return result; - } finally { - connectionPool.releaseConnection(host, client); + if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) { + throw new Error("Invalid private key format"); + } + + const cleanKey = host.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + (base as any).privateKey = Buffer.from(cleanKey, "utf8"); + + if (host.keyPassword) { + (base as any).passphrase = host.keyPassword; + } + } catch (keyError) { + statsLogger.error( + `SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, + ); + throw new Error(`Invalid SSH key format for host ${host.ip}`); } + } else { + throw new Error( + `Unsupported authentication type '${host.authType}' for host ${host.ip}`, + ); + } + + return base; } -function execCommand(client: Client, command: string): Promise<{ - stdout: string; - stderr: string; - code: number | null; +async function withSshConnection( + host: SSHHostWithCredentials, + fn: (client: Client) => Promise, +): Promise { + const client = await connectionPool.getConnection(host); + try { + const result = await fn(client); + return result; + } finally { + connectionPool.releaseConnection(host, client); + } +} + +function execCommand( + client: Client, + command: string, +): Promise<{ + stdout: string; + stderr: string; + code: number | null; }> { - return new Promise((resolve, reject) => { - client.exec(command, {pty: false}, (err, stream) => { - if (err) return reject(err); - let stdout = ''; - let stderr = ''; - let exitCode: number | null = null; - stream.on('close', (code: number | undefined) => { - exitCode = typeof code === 'number' ? code : null; - resolve({stdout, stderr, code: exitCode}); - }).on('data', (data: Buffer) => { - stdout += data.toString('utf8'); - }).stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf8'); - }); + return new Promise((resolve, reject) => { + client.exec(command, { pty: false }, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let stderr = ""; + let exitCode: number | null = null; + stream + .on("close", (code: number | undefined) => { + exitCode = typeof code === "number" ? code : null; + resolve({ stdout, stderr, code: exitCode }); + }) + .on("data", (data: Buffer) => { + stdout += data.toString("utf8"); + }) + .stderr.on("data", (data: Buffer) => { + stderr += data.toString("utf8"); }); }); + }); } -function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined { - const parts = cpuLine.trim().split(/\s+/); - if (parts[0] !== 'cpu') return undefined; - const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n)); - if (nums.length < 4) return undefined; - const idle = (nums[3] ?? 0) + (nums[4] ?? 0); - const total = nums.reduce((a, b) => a + b, 0); - return {total, idle}; +function parseCpuLine( + cpuLine: string, +): { total: number; idle: number } | undefined { + const parts = cpuLine.trim().split(/\s+/); + if (parts[0] !== "cpu") return undefined; + const nums = parts + .slice(1) + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n)); + if (nums.length < 4) return undefined; + const idle = (nums[3] ?? 0) + (nums[4] ?? 0); + const total = nums.reduce((a, b) => a + b, 0); + return { total, idle }; } function toFixedNum(n: number | null | undefined, digits = 2): number | null { - if (typeof n !== 'number' || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); } function kibToGiB(kib: number): number { - return kib / (1024 * 1024); + return kib / (1024 * 1024); } async function collectMetrics(host: SSHHostWithCredentials): Promise<{ - cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null }; - memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }; - disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null }; + cpu: { + percent: number | null; + cores: number | null; + load: [number, number, number] | null; + }; + memory: { + percent: number | null; + usedGiB: number | null; + totalGiB: number | null; + }; + disk: { + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + }; }> { - const cached = metricsCache.get(host.id); - if (cached) { - return cached; - } + const cached = metricsCache.get(host.id); + if (cached) { + return cached; + } - return requestQueue.queueRequest(host.id, async () => { - return withSshConnection(host, async (client) => { - let cpuPercent: number | null = null; - let cores: number | null = null; - let loadTriplet: [number, number, number] | null = null; + return requestQueue.queueRequest(host.id, async () => { + return withSshConnection(host, async (client) => { + let cpuPercent: number | null = null; + let cores: number | null = null; + let loadTriplet: [number, number, number] | null = null; - try { - const [stat1, loadAvgOut, coresOut] = await Promise.all([ - execCommand(client, 'cat /proc/stat'), - execCommand(client, 'cat /proc/loadavg'), - execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo') - ]); + try { + const [stat1, loadAvgOut, coresOut] = await Promise.all([ + execCommand(client, "cat /proc/stat"), + execCommand(client, "cat /proc/loadavg"), + execCommand( + client, + "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", + ), + ]); - await new Promise(r => setTimeout(r, 500)); - const stat2 = await execCommand(client, 'cat /proc/stat'); + await new Promise((r) => setTimeout(r, 500)); + const stat2 = await execCommand(client, "cat /proc/stat"); - const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim(); - const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim(); - const a = parseCpuLine(cpuLine1); - const b = parseCpuLine(cpuLine2); - if (a && b) { - const totalDiff = b.total - a.total; - const idleDiff = b.idle - a.idle; - const used = totalDiff - idleDiff; - if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); - } + const cpuLine1 = ( + stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const cpuLine2 = ( + stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const a = parseCpuLine(cpuLine1); + const b = parseCpuLine(cpuLine2); + if (a && b) { + const totalDiff = b.total - a.total; + const idleDiff = b.idle - a.idle; + const used = totalDiff - idleDiff; + if (totalDiff > 0) + cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); + } - const laParts = loadAvgOut.stdout.trim().split(/\s+/); - if (laParts.length >= 3) { - loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number]; - } + const laParts = loadAvgOut.stdout.trim().split(/\s+/); + if (laParts.length >= 3) { + loadTriplet = [ + Number(laParts[0]), + Number(laParts[1]), + Number(laParts[2]), + ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ + number, + number, + number, + ]; + } - const coresNum = Number((coresOut.stdout || '').trim()); - cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; - } catch (e) { - statsLogger.warn(`Failed to collect CPU metrics for host ${host.id}`, e); - cpuPercent = null; - cores = null; - loadTriplet = null; - } + const coresNum = Number((coresOut.stdout || "").trim()); + cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; + } catch (e) { + statsLogger.warn( + `Failed to collect CPU metrics for host ${host.id}`, + e, + ); + cpuPercent = null; + cores = null; + loadTriplet = null; + } - let memPercent: number | null = null; - let usedGiB: number | null = null; - let totalGiB: number | null = null; - try { - const memInfo = await execCommand(client, 'cat /proc/meminfo'); - const lines = memInfo.stdout.split('\n'); - const getVal = (key: string) => { - const line = lines.find(l => l.startsWith(key)); - if (!line) return null; - const m = line.match(/\d+/); - return m ? Number(m[0]) : null; - }; - const totalKb = getVal('MemTotal:'); - const availKb = getVal('MemAvailable:'); - if (totalKb && availKb && totalKb > 0) { - const usedKb = totalKb - availKb; - memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); - usedGiB = kibToGiB(usedKb); - totalGiB = kibToGiB(totalKb); - } - } catch (e) { - statsLogger.warn(`Failed to collect memory metrics for host ${host.id}`, e); - memPercent = null; - usedGiB = null; - totalGiB = null; - } + let memPercent: number | null = null; + let usedGiB: number | null = null; + let totalGiB: number | null = null; + try { + const memInfo = await execCommand(client, "cat /proc/meminfo"); + const lines = memInfo.stdout.split("\n"); + const getVal = (key: string) => { + const line = lines.find((l) => l.startsWith(key)); + if (!line) return null; + const m = line.match(/\d+/); + return m ? Number(m[0]) : null; + }; + const totalKb = getVal("MemTotal:"); + const availKb = getVal("MemAvailable:"); + if (totalKb && availKb && totalKb > 0) { + const usedKb = totalKb - availKb; + memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); + usedGiB = kibToGiB(usedKb); + totalGiB = kibToGiB(totalKb); + } + } catch (e) { + statsLogger.warn( + `Failed to collect memory metrics for host ${host.id}`, + e, + ); + memPercent = null; + usedGiB = null; + totalGiB = null; + } - let diskPercent: number | null = null; - let usedHuman: string | null = null; - let totalHuman: string | null = null; - try { - const [diskOutHuman, diskOutBytes] = await Promise.all([ - execCommand(client, 'df -h -P / | tail -n +2'), - execCommand(client, 'df -B1 -P / | tail -n +2') - ]); + let diskPercent: number | null = null; + let usedHuman: string | null = null; + let totalHuman: string | null = null; + try { + const [diskOutHuman, diskOutBytes] = await Promise.all([ + execCommand(client, "df -h -P / | tail -n +2"), + execCommand(client, "df -B1 -P / | tail -n +2"), + ]); - const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; - const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; + const humanLine = + diskOutHuman.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; + const bytesLine = + diskOutBytes.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; - const humanParts = humanLine.split(/\s+/); - const bytesParts = bytesLine.split(/\s+/); + const humanParts = humanLine.split(/\s+/); + const bytesParts = bytesLine.split(/\s+/); - if (humanParts.length >= 6 && bytesParts.length >= 6) { - totalHuman = humanParts[1] || null; - usedHuman = humanParts[2] || null; + if (humanParts.length >= 6 && bytesParts.length >= 6) { + totalHuman = humanParts[1] || null; + usedHuman = humanParts[2] || null; - const totalBytes = Number(bytesParts[1]); - const usedBytes = Number(bytesParts[2]); + const totalBytes = Number(bytesParts[1]); + const usedBytes = Number(bytesParts[2]); - if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) { - diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)); - } - } - } catch (e) { - statsLogger.warn(`Failed to collect disk metrics for host ${host.id}`, e); - diskPercent = null; - usedHuman = null; - totalHuman = null; - } + if ( + Number.isFinite(totalBytes) && + Number.isFinite(usedBytes) && + totalBytes > 0 + ) { + diskPercent = Math.max( + 0, + Math.min(100, (usedBytes / totalBytes) * 100), + ); + } + } + } catch (e) { + statsLogger.warn( + `Failed to collect disk metrics for host ${host.id}`, + e, + ); + diskPercent = null; + usedHuman = null; + totalHuman = null; + } - const result = { - cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet}, - memory: { - percent: toFixedNum(memPercent, 0), - usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, - totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null - }, - disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman}, - }; + const result = { + cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, + memory: { + percent: toFixedNum(memPercent, 0), + usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, + totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, + }, + disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, + }; - metricsCache.set(host.id, result); - return result; - }); + metricsCache.set(host.id, result); + return result; }); + }); } -function tcpPing(host: string, port: number, timeoutMs = 5000): Promise { - return new Promise((resolve) => { - const socket = new net.Socket(); - let settled = false; +function tcpPing( + host: string, + port: number, + timeoutMs = 5000, +): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + let settled = false; - const onDone = (result: boolean) => { - if (settled) return; - settled = true; - try { - socket.destroy(); - } catch { - } - resolve(result); - }; + const onDone = (result: boolean) => { + if (settled) return; + settled = true; + try { + socket.destroy(); + } catch {} + resolve(result); + }; - socket.setTimeout(timeoutMs); + socket.setTimeout(timeoutMs); - socket.once('connect', () => onDone(true)); - socket.once('timeout', () => onDone(false)); - socket.once('error', () => onDone(false)); - socket.connect(port, host); - }); + socket.once("connect", () => onDone(true)); + socket.once("timeout", () => onDone(false)); + socket.once("error", () => onDone(false)); + socket.connect(port, host); + }); } async function pollStatusesOnce(): Promise { - const hosts = await fetchAllHosts(); - if (hosts.length === 0) { - statsLogger.warn('No hosts retrieved for status polling', {operation: 'status_poll'}); - return; - } + const hosts = await fetchAllHosts(); + if (hosts.length === 0) { + statsLogger.warn("No hosts retrieved for status polling", { + operation: "status_poll", + }); + return; + } + const now = new Date().toISOString(); + + const checks = hosts.map(async (h) => { + const isOnline = await tcpPing(h.ip, h.port, 5000); const now = new Date().toISOString(); + const statusEntry: StatusEntry = { + status: isOnline ? "online" : "offline", + lastChecked: now, + }; + hostStatuses.set(h.id, statusEntry); + return isOnline; + }); - const checks = hosts.map(async (h) => { - const isOnline = await tcpPing(h.ip, h.port, 5000); - const now = new Date().toISOString(); - const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; - hostStatuses.set(h.id, statusEntry); - return isOnline; - }); - - const results = await Promise.allSettled(checks); - const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length; - const offlineCount = hosts.length - onlineCount; - statsLogger.success('Status polling completed', { - operation: 'status_poll', - totalHosts: hosts.length, - onlineCount, - offlineCount - }); + const results = await Promise.allSettled(checks); + const onlineCount = results.filter( + (r) => r.status === "fulfilled" && r.value === true, + ).length; + const offlineCount = hosts.length - onlineCount; + statsLogger.success("Status polling completed", { + operation: "status_poll", + totalHosts: hosts.length, + onlineCount, + offlineCount, + }); } -app.get('/status', async (req, res) => { - if (hostStatuses.size === 0) { - await pollStatusesOnce(); - } - const result: Record = {}; - for (const [id, entry] of hostStatuses.entries()) { - result[id] = entry; - } - res.json(result); -}); - -app.get('/status/:id', validateHostId, async (req, res) => { - const id = Number(req.params.id); - - try { - const host = await fetchHostById(id); - if (!host) { - return res.status(404).json({error: 'Host not found'}); - } - - const isOnline = await tcpPing(host.ip, host.port, 5000); - const now = new Date().toISOString(); - const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; - - hostStatuses.set(id, statusEntry); - res.json(statusEntry); - } catch (err) { - statsLogger.error('Failed to check host status', err); - res.status(500).json({error: 'Failed to check host status'}); - } -}); - -app.post('/refresh', async (req, res) => { +app.get("/status", async (req, res) => { + if (hostStatuses.size === 0) { await pollStatusesOnce(); - res.json({message: 'Refreshed'}); + } + const result: Record = {}; + for (const [id, entry] of hostStatuses.entries()) { + result[id] = entry; + } + res.json(result); }); -app.get('/metrics/:id', validateHostId, async (req, res) => { - const id = Number(req.params.id); +app.get("/status/:id", validateHostId, async (req, res) => { + const id = Number(req.params.id); - try { - const host = await fetchHostById(id); - if (!host) { - return res.status(404).json({error: 'Host not found'}); - } - - const isOnline = await tcpPing(host.ip, host.port, 5000); - if (!isOnline) { - return res.status(503).json({ - error: 'Host is offline', - cpu: {percent: null, cores: null, load: null}, - memory: {percent: null, usedGiB: null, totalGiB: null}, - disk: {percent: null, usedHuman: null, totalHuman: null}, - lastChecked: new Date().toISOString() - }); - } - - const metrics = await collectMetrics(host); - res.json({...metrics, lastChecked: new Date().toISOString()}); - } catch (err) { - statsLogger.error('Failed to collect metrics', err); - - if (err instanceof Error && err.message.includes('timeout')) { - return res.status(504).json({ - error: 'Metrics collection timeout', - cpu: {percent: null, cores: null, load: null}, - memory: {percent: null, usedGiB: null, totalGiB: null}, - disk: {percent: null, usedHuman: null, totalHuman: null}, - lastChecked: new Date().toISOString() - }); - } - - return res.status(500).json({ - error: 'Failed to collect metrics', - cpu: {percent: null, cores: null, load: null}, - memory: {percent: null, usedGiB: null, totalGiB: null}, - disk: {percent: null, usedHuman: null, totalHuman: null}, - lastChecked: new Date().toISOString() - }); + try { + const host = await fetchHostById(id); + if (!host) { + return res.status(404).json({ error: "Host not found" }); } + + const isOnline = await tcpPing(host.ip, host.port, 5000); + const now = new Date().toISOString(); + const statusEntry: StatusEntry = { + status: isOnline ? "online" : "offline", + lastChecked: now, + }; + + hostStatuses.set(id, statusEntry); + res.json(statusEntry); + } catch (err) { + statsLogger.error("Failed to check host status", err); + res.status(500).json({ error: "Failed to check host status" }); + } }); -process.on('SIGINT', () => { - statsLogger.info('Received SIGINT, shutting down gracefully'); - connectionPool.destroy(); - process.exit(0); +app.post("/refresh", async (req, res) => { + await pollStatusesOnce(); + res.json({ message: "Refreshed" }); }); -process.on('SIGTERM', () => { - statsLogger.info('Received SIGTERM, shutting down gracefully'); - connectionPool.destroy(); - process.exit(0); +app.get("/metrics/:id", validateHostId, async (req, res) => { + const id = Number(req.params.id); + + try { + const host = await fetchHostById(id); + if (!host) { + return res.status(404).json({ error: "Host not found" }); + } + + const isOnline = await tcpPing(host.ip, host.port, 5000); + if (!isOnline) { + return res.status(503).json({ + error: "Host is offline", + cpu: { percent: null, cores: null, load: null }, + memory: { percent: null, usedGiB: null, totalGiB: null }, + disk: { percent: null, usedHuman: null, totalHuman: null }, + lastChecked: new Date().toISOString(), + }); + } + + const metrics = await collectMetrics(host); + res.json({ ...metrics, lastChecked: new Date().toISOString() }); + } catch (err) { + statsLogger.error("Failed to collect metrics", err); + + if (err instanceof Error && err.message.includes("timeout")) { + return res.status(504).json({ + error: "Metrics collection timeout", + cpu: { percent: null, cores: null, load: null }, + memory: { percent: null, usedGiB: null, totalGiB: null }, + disk: { percent: null, usedHuman: null, totalHuman: null }, + lastChecked: new Date().toISOString(), + }); + } + + return res.status(500).json({ + error: "Failed to collect metrics", + cpu: { percent: null, cores: null, load: null }, + memory: { percent: null, usedGiB: null, totalGiB: null }, + disk: { percent: null, usedHuman: null, totalHuman: null }, + lastChecked: new Date().toISOString(), + }); + } +}); + +process.on("SIGINT", () => { + statsLogger.info("Received SIGINT, shutting down gracefully"); + connectionPool.destroy(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + statsLogger.info("Received SIGTERM, shutting down gracefully"); + connectionPool.destroy(); + process.exit(0); }); const PORT = 8085; app.listen(PORT, async () => { - statsLogger.success('Server Stats API server started', {operation: 'server_start', port: PORT}); - try { - await pollStatusesOnce(); - } catch (err) { - statsLogger.error('Initial poll failed', err, {operation: 'initial_poll'}); - } -}); \ No newline at end of file + statsLogger.success("Server Stats API server started", { + operation: "server_start", + port: PORT, + }); + try { + await pollStatusesOnce(); + } catch (err) { + statsLogger.error("Initial poll failed", err, { + operation: "initial_poll", + }); + } +}); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 51ca5d81..cb1ec180 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,399 +1,498 @@ -import {WebSocketServer, WebSocket, type RawData} from 'ws'; -import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; -import {db} from '../database/db/index.js'; -import {sshCredentials} from '../database/db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import {sshLogger} from '../utils/logger.js'; +import { WebSocketServer, WebSocket, type RawData } from "ws"; +import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; +import { db } from "../database/db/index.js"; +import { sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { sshLogger } from "../utils/logger.js"; -const wss = new WebSocketServer({port: 8082}); +const wss = new WebSocketServer({ port: 8082 }); -sshLogger.success('SSH Terminal WebSocket server started', {operation: 'server_start', port: 8082}); - -wss.on('connection', (ws: WebSocket) => { - let sshConn: Client | null = null; - let sshStream: ClientChannel | null = null; - let pingInterval: NodeJS.Timeout | null = null; - - ws.on('close', () => { - cleanupSSH(); - }); - - ws.on('message', (msg: RawData) => { - let parsed: any; - try { - parsed = JSON.parse(msg.toString()); - } catch (e) { - sshLogger.error('Invalid JSON received', e, { - operation: 'websocket_message', - messageLength: msg.toString().length - }); - ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); - return; - } - - const {type, data} = parsed; - - switch (type) { - case 'connectToHost': - handleConnectToHost(data).catch(error => { - sshLogger.error('Failed to connect to host', error, { - operation: 'ssh_connect', - hostId: data.hostConfig?.id, - ip: data.hostConfig?.ip - }); - ws.send(JSON.stringify({ - type: 'error', - message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error') - })); - }); - break; - - case 'resize': - handleResize(data); - break; - - case 'disconnect': - cleanupSSH(); - break; - - case 'input': - if (sshStream) { - if (data === '\t') { - sshStream.write(data); - } else if (data.startsWith('\x1b')) { - sshStream.write(data); - } else { - sshStream.write(Buffer.from(data, 'utf8')); - } - } - break; - - case 'ping': - ws.send(JSON.stringify({type: 'pong'})); - break; - - default: - sshLogger.warn('Unknown message type received', {operation: 'websocket_message', messageType: type}); - } - }); - - async function handleConnectToHost(data: { - cols: number; - rows: number; - hostConfig: { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - userId?: string; - }; - }) { - const {cols, rows, hostConfig} = data; - const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig; - - if (!username || typeof username !== 'string' || username.trim() === '') { - sshLogger.error('Invalid username provided', undefined, {operation: 'ssh_connect', hostId: id, ip}); - ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); - return; - } - - if (!ip || typeof ip !== 'string' || ip.trim() === '') { - sshLogger.error('Invalid IP provided', undefined, {operation: 'ssh_connect', hostId: id, username}); - ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); - return; - } - - if (!port || typeof port !== 'number' || port <= 0) { - sshLogger.error('Invalid port provided', undefined, { - operation: 'ssh_connect', - hostId: id, - ip, - username, - port - }); - ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'})); - return; - } - - sshConn = new Client(); - - const connectionTimeout = setTimeout(() => { - if (sshConn) { - sshLogger.error('SSH connection timeout', undefined, { - operation: 'ssh_connect', - hostId: id, - ip, - port, - username - }); - ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); - cleanupSSH(connectionTimeout); - } - }, 60000); - - let resolvedCredentials = {password, key, keyPassword, keyType, authType}; - if (credentialId && id && hostConfig.userId) { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, hostConfig.userId) - )); - - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedCredentials = { - password: credential.password, - key: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authType: credential.authType - }; - } else { - sshLogger.warn(`No credentials found for host ${id}`, { - operation: 'ssh_credentials', - hostId: id, - credentialId, - userId: hostConfig.userId - }); - } - } catch (error) { - sshLogger.warn(`Failed to resolve credentials for host ${id}`, { - operation: 'ssh_credentials', - hostId: id, - credentialId, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } else if (credentialId && id) { - sshLogger.warn('Missing userId for credential resolution in terminal', { - operation: 'ssh_credentials', - hostId: id, - credentialId, - hasUserId: !!hostConfig.userId - }); - } - - sshConn.on('ready', () => { - clearTimeout(connectionTimeout); - - - sshConn!.shell({ - rows: data.rows, - cols: data.cols, - term: 'xterm-256color' - } as PseudoTtyOptions, (err, stream) => { - if (err) { - sshLogger.error('Shell error', err, {operation: 'ssh_shell', hostId: id, ip, port, username}); - ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); - return; - } - - sshStream = stream; - - stream.on('data', (data: Buffer) => { - ws.send(JSON.stringify({type: 'data', data: data.toString()})); - }); - - stream.on('close', () => { - ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); - }); - - stream.on('error', (err: Error) => { - sshLogger.error('SSH stream error', err, {operation: 'ssh_stream', hostId: id, ip, port, username}); - ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); - }); - - setupPingInterval(); - - ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'})); - }); - }); - - sshConn.on('error', (err: Error) => { - clearTimeout(connectionTimeout); - sshLogger.error('SSH connection error', err, { - operation: 'ssh_connect', - hostId: id, - ip, - port, - username, - authType: resolvedCredentials.authType - }); - - let errorMessage = 'SSH error: ' + err.message; - if (err.message.includes('No matching key exchange algorithm')) { - errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.'; - } else if (err.message.includes('No matching cipher')) { - errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.'; - } else if (err.message.includes('No matching MAC')) { - errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.'; - } else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) { - errorMessage = 'SSH error: Could not resolve hostname or connect to server.'; - } else if (err.message.includes('ECONNREFUSED')) { - errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.'; - } else if (err.message.includes('ETIMEDOUT')) { - errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.'; - } else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) { - errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.'; - } else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) { - errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.'; - } - - ws.send(JSON.stringify({type: 'error', message: errorMessage})); - cleanupSSH(connectionTimeout); - }); - - sshConn.on('close', () => { - clearTimeout(connectionTimeout); - cleanupSSH(connectionTimeout); - }); - - const connectConfig: any = { - host: ip, - port, - username, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - - env: { - TERM: 'xterm-256color', - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - LC_CTYPE: 'en_US.UTF-8', - LC_MESSAGES: 'en_US.UTF-8', - LC_MONETARY: 'en_US.UTF-8', - LC_NUMERIC: 'en_US.UTF-8', - LC_TIME: 'en_US.UTF-8', - LC_COLLATE: 'en_US.UTF-8', - COLORTERM: 'truecolor', - }, - - algorithms: { - kex: [ - 'diffie-hellman-group14-sha256', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521' - ], - cipher: [ - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm@openssh.com', - 'aes256-gcm@openssh.com', - 'aes128-cbc', - 'aes192-cbc', - 'aes256-cbc', - '3des-cbc' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - } - }; - if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) { - try { - if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) { - throw new Error('Invalid private key format'); - } - - const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - connectConfig.privateKey = Buffer.from(cleanKey, 'utf8'); - - if (resolvedCredentials.keyPassword) { - connectConfig.passphrase = resolvedCredentials.keyPassword; - } - - if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') { - connectConfig.privateKeyType = resolvedCredentials.keyType; - } - } catch (keyError) { - sshLogger.error('SSH key format error: ' + keyError.message); - ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'})); - return; - } - } else if (resolvedCredentials.authType === 'key') { - sshLogger.error('SSH key authentication requested but no key provided'); - ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'})); - return; - } else { - connectConfig.password = resolvedCredentials.password; - } - - sshConn.connect(connectConfig); - } - - function handleResize(data: { cols: number; rows: number }) { - if (sshStream && sshStream.setWindow) { - sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); - ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows})); - } - } - - function cleanupSSH(timeoutId?: NodeJS.Timeout) { - if (timeoutId) { - clearTimeout(timeoutId); - } - - if (pingInterval) { - clearInterval(pingInterval); - pingInterval = null; - } - - if (sshStream) { - try { - sshStream.end(); - } catch (e: any) { - sshLogger.error('Error closing stream: ' + e.message); - } - sshStream = null; - } - - if (sshConn) { - try { - sshConn.end(); - } catch (e: any) { - sshLogger.error('Error closing connection: ' + e.message); - } - sshConn = null; - } - } - - function setupPingInterval() { - pingInterval = setInterval(() => { - if (sshConn && sshStream) { - try { - sshStream.write('\x00'); - } catch (e: any) { - sshLogger.error('SSH keepalive failed: ' + e.message); - cleanupSSH(); - } - } - }, 60000); - } +sshLogger.success("SSH Terminal WebSocket server started", { + operation: "server_start", + port: 8082, +}); + +wss.on("connection", (ws: WebSocket) => { + let sshConn: Client | null = null; + let sshStream: ClientChannel | null = null; + let pingInterval: NodeJS.Timeout | null = null; + + ws.on("close", () => { + cleanupSSH(); + }); + + ws.on("message", (msg: RawData) => { + let parsed: any; + try { + parsed = JSON.parse(msg.toString()); + } catch (e) { + sshLogger.error("Invalid JSON received", e, { + operation: "websocket_message", + messageLength: msg.toString().length, + }); + ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + return; + } + + const { type, data } = parsed; + + switch (type) { + case "connectToHost": + handleConnectToHost(data).catch((error) => { + sshLogger.error("Failed to connect to host", error, { + operation: "ssh_connect", + hostId: data.hostConfig?.id, + ip: data.hostConfig?.ip, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect to host: " + + (error instanceof Error ? error.message : "Unknown error"), + }), + ); + }); + break; + + case "resize": + handleResize(data); + break; + + case "disconnect": + cleanupSSH(); + break; + + case "input": + if (sshStream) { + if (data === "\t") { + sshStream.write(data); + } else if (data.startsWith("\x1b")) { + sshStream.write(data); + } else { + sshStream.write(Buffer.from(data, "utf8")); + } + } + break; + + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; + + default: + sshLogger.warn("Unknown message type received", { + operation: "websocket_message", + messageType: type, + }); + } + }); + + async function handleConnectToHost(data: { + cols: number; + rows: number; + hostConfig: { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + userId?: string; + }; + }) { + const { cols, rows, hostConfig } = data; + const { + id, + ip, + port, + username, + password, + key, + keyPassword, + keyType, + authType, + credentialId, + } = hostConfig; + + if (!username || typeof username !== "string" || username.trim() === "") { + sshLogger.error("Invalid username provided", undefined, { + operation: "ssh_connect", + hostId: id, + ip, + }); + ws.send( + JSON.stringify({ type: "error", message: "Invalid username provided" }), + ); + return; + } + + if (!ip || typeof ip !== "string" || ip.trim() === "") { + sshLogger.error("Invalid IP provided", undefined, { + operation: "ssh_connect", + hostId: id, + username, + }); + ws.send( + JSON.stringify({ type: "error", message: "Invalid IP provided" }), + ); + return; + } + + if (!port || typeof port !== "number" || port <= 0) { + sshLogger.error("Invalid port provided", undefined, { + operation: "ssh_connect", + hostId: id, + ip, + username, + port, + }); + ws.send( + JSON.stringify({ type: "error", message: "Invalid port provided" }), + ); + return; + } + + sshConn = new Client(); + + const connectionTimeout = setTimeout(() => { + if (sshConn) { + sshLogger.error("SSH connection timeout", undefined, { + operation: "ssh_connect", + hostId: id, + ip, + port, + username, + }); + ws.send( + JSON.stringify({ type: "error", message: "SSH connection timeout" }), + ); + cleanupSSH(connectionTimeout); + } + }, 60000); + + let resolvedCredentials = { password, key, keyPassword, keyType, authType }; + if (credentialId && id && hostConfig.userId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, hostConfig.userId), + ), + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authType: credential.authType, + }; + } else { + sshLogger.warn(`No credentials found for host ${id}`, { + operation: "ssh_credentials", + hostId: id, + credentialId, + userId: hostConfig.userId, + }); + } + } catch (error) { + sshLogger.warn(`Failed to resolve credentials for host ${id}`, { + operation: "ssh_credentials", + hostId: id, + credentialId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } else if (credentialId && id) { + sshLogger.warn("Missing userId for credential resolution in terminal", { + operation: "ssh_credentials", + hostId: id, + credentialId, + hasUserId: !!hostConfig.userId, + }); + } + + sshConn.on("ready", () => { + clearTimeout(connectionTimeout); + + sshConn!.shell( + { + rows: data.rows, + cols: data.cols, + term: "xterm-256color", + } as PseudoTtyOptions, + (err, stream) => { + if (err) { + sshLogger.error("Shell error", err, { + operation: "ssh_shell", + hostId: id, + ip, + port, + username, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Shell error: " + err.message, + }), + ); + return; + } + + sshStream = stream; + + stream.on("data", (data: Buffer) => { + ws.send(JSON.stringify({ type: "data", data: data.toString() })); + }); + + stream.on("close", () => { + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Connection lost", + }), + ); + }); + + stream.on("error", (err: Error) => { + sshLogger.error("SSH stream error", err, { + operation: "ssh_stream", + hostId: id, + ip, + port, + username, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH stream error: " + err.message, + }), + ); + }); + + setupPingInterval(); + + ws.send( + JSON.stringify({ type: "connected", message: "SSH connected" }), + ); + }, + ); + }); + + sshConn.on("error", (err: Error) => { + clearTimeout(connectionTimeout); + sshLogger.error("SSH connection error", err, { + operation: "ssh_connect", + hostId: id, + ip, + port, + username, + authType: resolvedCredentials.authType, + }); + + let errorMessage = "SSH error: " + err.message; + if (err.message.includes("No matching key exchange algorithm")) { + errorMessage = + "SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device."; + } else if (err.message.includes("No matching cipher")) { + errorMessage = + "SSH error: No compatible cipher found. This may be due to an older SSH server or network device."; + } else if (err.message.includes("No matching MAC")) { + errorMessage = + "SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device."; + } else if ( + err.message.includes("ENOTFOUND") || + err.message.includes("ENOENT") + ) { + errorMessage = + "SSH error: Could not resolve hostname or connect to server."; + } else if (err.message.includes("ECONNREFUSED")) { + errorMessage = + "SSH error: Connection refused. The server may not be running or the port may be incorrect."; + } else if (err.message.includes("ETIMEDOUT")) { + errorMessage = + "SSH error: Connection timed out. Check your network connection and server availability."; + } else if ( + err.message.includes("ECONNRESET") || + err.message.includes("EPIPE") + ) { + errorMessage = + "SSH error: Connection was reset. This may be due to network issues or server timeout."; + } else if ( + err.message.includes("authentication failed") || + err.message.includes("Permission denied") + ) { + errorMessage = + "SSH error: Authentication failed. Please check your username and password/key."; + } + + ws.send(JSON.stringify({ type: "error", message: errorMessage })); + cleanupSSH(connectionTimeout); + }); + + sshConn.on("close", () => { + clearTimeout(connectionTimeout); + cleanupSSH(connectionTimeout); + }); + + const connectConfig: any = { + host: ip, + port, + username, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, + + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + if (resolvedCredentials.authType === "key" && resolvedCredentials.key) { + try { + if ( + !resolvedCredentials.key.includes("-----BEGIN") || + !resolvedCredentials.key.includes("-----END") + ) { + throw new Error("Invalid private key format"); + } + + const cleanKey = resolvedCredentials.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + + if (resolvedCredentials.keyPassword) { + connectConfig.passphrase = resolvedCredentials.keyPassword; + } + + if ( + resolvedCredentials.keyType && + resolvedCredentials.keyType !== "auto" + ) { + connectConfig.privateKeyType = resolvedCredentials.keyType; + } + } catch (keyError) { + sshLogger.error("SSH key format error: " + keyError.message); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH key format error: Invalid private key format", + }), + ); + return; + } + } else if (resolvedCredentials.authType === "key") { + sshLogger.error("SSH key authentication requested but no key provided"); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH key authentication requested but no key provided", + }), + ); + return; + } else { + connectConfig.password = resolvedCredentials.password; + } + + sshConn.connect(connectConfig); + } + + function handleResize(data: { cols: number; rows: number }) { + if (sshStream && sshStream.setWindow) { + sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); + ws.send( + JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), + ); + } + } + + function cleanupSSH(timeoutId?: NodeJS.Timeout) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + + if (sshStream) { + try { + sshStream.end(); + } catch (e: any) { + sshLogger.error("Error closing stream: " + e.message); + } + sshStream = null; + } + + if (sshConn) { + try { + sshConn.end(); + } catch (e: any) { + sshLogger.error("Error closing connection: " + e.message); + } + sshConn = null; + } + } + + function setupPingInterval() { + pingInterval = setInterval(() => { + if (sshConn && sshStream) { + try { + sshStream.write("\x00"); + } catch (e: any) { + sshLogger.error("SSH keepalive failed: " + e.message); + cleanupSSH(); + } + } + }, 60000); + } }); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 817ba5dc..07edaa76 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1,30 +1,31 @@ -import express from 'express'; -import cors from 'cors'; -import {Client} from 'ssh2'; -import {ChildProcess} from 'child_process'; -import axios from 'axios'; -import {db} from '../database/db/index.js'; -import {sshCredentials} from '../database/db/schema.js'; -import {eq, and} from 'drizzle-orm'; +import express from "express"; +import cors from "cors"; +import { Client } from "ssh2"; +import { ChildProcess } from "child_process"; +import axios from "axios"; +import { db } from "../database/db/index.js"; +import { sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; import type { - SSHHost, - TunnelConfig, - TunnelStatus, - VerificationData, - ErrorType -} from '../../types/index.js'; -import {CONNECTION_STATES} from '../../types/index.js'; -import {tunnelLogger} from '../utils/logger.js'; + SSHHost, + TunnelConfig, + TunnelStatus, + VerificationData, + ErrorType, +} from "../../types/index.js"; +import { CONNECTION_STATES } from "../../types/index.js"; +import { tunnelLogger } from "../utils/logger.js"; const app = express(); -app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization', -})); +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: "Origin,X-Requested-With,Content-Type,Accept,Authorization", + }), +); app.use(express.json()); - const activeTunnels = new Map(); const retryCounters = new Map(); const connectionStatus = new Map(); @@ -39,980 +40,1067 @@ const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { - if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) { - return; - } + if ( + status.status === CONNECTION_STATES.CONNECTED && + activeRetryTimers.has(tunnelName) + ) { + return; + } - if (retryExhaustedTunnels.has(tunnelName) && status.status === CONNECTION_STATES.FAILED) { - status.reason = "Max retries exhausted"; - } + if ( + retryExhaustedTunnels.has(tunnelName) && + status.status === CONNECTION_STATES.FAILED + ) { + status.reason = "Max retries exhausted"; + } - connectionStatus.set(tunnelName, status); + connectionStatus.set(tunnelName, status); } function getAllTunnelStatus(): Record { - const tunnelStatus: Record = {}; - connectionStatus.forEach((status, key) => { - tunnelStatus[key] = status; - }); - return tunnelStatus; + const tunnelStatus: Record = {}; + connectionStatus.forEach((status, key) => { + tunnelStatus[key] = status; + }); + return tunnelStatus; } function classifyError(errorMessage: string): ErrorType { - if (!errorMessage) return 'UNKNOWN'; + if (!errorMessage) return "UNKNOWN"; - const message = errorMessage.toLowerCase(); + const message = errorMessage.toLowerCase(); - if (message.includes("closed by remote host") || - message.includes("connection reset by peer") || - message.includes("connection refused") || - message.includes("broken pipe")) { - return 'NETWORK_ERROR'; - } + if ( + message.includes("closed by remote host") || + message.includes("connection reset by peer") || + message.includes("connection refused") || + message.includes("broken pipe") + ) { + return "NETWORK_ERROR"; + } - if (message.includes("authentication failed") || - message.includes("permission denied") || - message.includes("incorrect password")) { - return 'AUTHENTICATION_FAILED'; - } + if ( + message.includes("authentication failed") || + message.includes("permission denied") || + message.includes("incorrect password") + ) { + return "AUTHENTICATION_FAILED"; + } - if (message.includes("connect etimedout") || - message.includes("timeout") || - message.includes("timed out") || - message.includes("keepalive timeout")) { - return 'TIMEOUT'; - } + if ( + message.includes("connect etimedout") || + message.includes("timeout") || + message.includes("timed out") || + message.includes("keepalive timeout") + ) { + return "TIMEOUT"; + } - if (message.includes("bind: address already in use") || - message.includes("failed for listen port") || - message.includes("port forwarding failed")) { - return 'CONNECTION_FAILED'; - } + if ( + message.includes("bind: address already in use") || + message.includes("failed for listen port") || + message.includes("port forwarding failed") + ) { + return "CONNECTION_FAILED"; + } - if (message.includes("permission") || - message.includes("access denied")) { - return 'CONNECTION_FAILED'; - } + if (message.includes("permission") || message.includes("access denied")) { + return "CONNECTION_FAILED"; + } - return 'UNKNOWN'; + return "UNKNOWN"; } function getTunnelMarker(tunnelName: string) { - return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; + return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; } function cleanupTunnelResources(tunnelName: string): void { - const tunnelConfig = tunnelConfigs.get(tunnelName); - if (tunnelConfig) { - killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { - if (err) { - tunnelLogger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`); - } - }); - } - - if (activeTunnelProcesses.has(tunnelName)) { - try { - const proc = activeTunnelProcesses.get(tunnelName); - if (proc) { - proc.kill('SIGTERM'); - } - } catch (e) { - tunnelLogger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e); - } - activeTunnelProcesses.delete(tunnelName); - } - - if (activeTunnels.has(tunnelName)) { - try { - const conn = activeTunnels.get(tunnelName); - if (conn) { - conn.end(); - } - } catch (e) { - tunnelLogger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e); - } - activeTunnels.delete(tunnelName); - } - - if (tunnelVerifications.has(tunnelName)) { - const verification = tunnelVerifications.get(tunnelName); - if (verification?.timeout) clearTimeout(verification.timeout); - try { - verification?.conn.end(); - } catch (e) { - } - tunnelVerifications.delete(tunnelName); - } - - const timerKeys = [ - tunnelName, - `${tunnelName}_confirm`, - `${tunnelName}_retry`, - `${tunnelName}_verify_retry`, - `${tunnelName}_ping` - ]; - - timerKeys.forEach(key => { - if (verificationTimers.has(key)) { - clearTimeout(verificationTimers.get(key)!); - verificationTimers.delete(key); - } + const tunnelConfig = tunnelConfigs.get(tunnelName); + if (tunnelConfig) { + killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { + if (err) { + tunnelLogger.error( + `Failed to kill remote tunnel for '${tunnelName}': ${err.message}`, + ); + } }); + } - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); + if (activeTunnelProcesses.has(tunnelName)) { + try { + const proc = activeTunnelProcesses.get(tunnelName); + if (proc) { + proc.kill("SIGTERM"); + } + } catch (e) { + tunnelLogger.error( + `Error while killing local ssh process for tunnel '${tunnelName}'`, + e, + ); } + activeTunnelProcesses.delete(tunnelName); + } - if (countdownIntervals.has(tunnelName)) { - clearInterval(countdownIntervals.get(tunnelName)!); - countdownIntervals.delete(tunnelName); + if (activeTunnels.has(tunnelName)) { + try { + const conn = activeTunnels.get(tunnelName); + if (conn) { + conn.end(); + } + } catch (e) { + tunnelLogger.error( + `Error while closing SSH2 Client for tunnel '${tunnelName}'`, + e, + ); } + activeTunnels.delete(tunnelName); + } + + if (tunnelVerifications.has(tunnelName)) { + const verification = tunnelVerifications.get(tunnelName); + if (verification?.timeout) clearTimeout(verification.timeout); + try { + verification?.conn.end(); + } catch (e) {} + tunnelVerifications.delete(tunnelName); + } + + const timerKeys = [ + tunnelName, + `${tunnelName}_confirm`, + `${tunnelName}_retry`, + `${tunnelName}_verify_retry`, + `${tunnelName}_ping`, + ]; + + timerKeys.forEach((key) => { + if (verificationTimers.has(key)) { + clearTimeout(verificationTimers.get(key)!); + verificationTimers.delete(key); + } + }); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } } function resetRetryState(tunnelName: string): void { - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } + + ["", "_confirm", "_retry", "_verify_retry", "_ping"].forEach((suffix) => { + const timerKey = `${tunnelName}${suffix}`; + if (verificationTimers.has(timerKey)) { + clearTimeout(verificationTimers.get(timerKey)!); + verificationTimers.delete(timerKey); } - - if (countdownIntervals.has(tunnelName)) { - clearInterval(countdownIntervals.get(tunnelName)!); - countdownIntervals.delete(tunnelName); - } - - ['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => { - const timerKey = `${tunnelName}${suffix}`; - if (verificationTimers.has(timerKey)) { - clearTimeout(verificationTimers.get(timerKey)!); - verificationTimers.delete(timerKey); - } - }); + }); } -function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true): void { - if (tunnelVerifications.has(tunnelName)) { - try { - const verification = tunnelVerifications.get(tunnelName); - if (verification?.timeout) clearTimeout(verification.timeout); - verification?.conn.end(); - } catch (e) { - } - tunnelVerifications.delete(tunnelName); +function handleDisconnect( + tunnelName: string, + tunnelConfig: TunnelConfig | null, + shouldRetry = true, +): void { + if (tunnelVerifications.has(tunnelName)) { + try { + const verification = tunnelVerifications.get(tunnelName); + if (verification?.timeout) clearTimeout(verification.timeout); + verification?.conn.end(); + } catch (e) {} + tunnelVerifications.delete(tunnelName); + } + + cleanupTunnelResources(tunnelName); + + if (manualDisconnects.has(tunnelName)) { + resetRetryState(tunnelName); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + return; + } + + if (retryExhaustedTunnels.has(tunnelName)) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Max retries already exhausted", + }); + return; + } + + if (activeRetryTimers.has(tunnelName)) { + return; + } + + if (shouldRetry && tunnelConfig) { + const maxRetries = tunnelConfig.maxRetries || 3; + const retryInterval = tunnelConfig.retryInterval || 5000; + + let retryCount = retryCounters.get(tunnelName) || 0; + retryCount = retryCount + 1; + + if (retryCount > maxRetries) { + tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`); + + retryExhaustedTunnels.add(tunnelName); + activeTunnels.delete(tunnelName); + retryCounters.delete(tunnelName); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + retryExhausted: true, + reason: `Max retries exhausted`, + }); + return; } - cleanupTunnelResources(tunnelName); + retryCounters.set(tunnelName, retryCount); - if (manualDisconnects.has(tunnelName)) { - resetRetryState(tunnelName); + if (retryCount <= maxRetries) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.RETRYING, + retryCount: retryCount, + maxRetries: maxRetries, + nextRetryIn: retryInterval / 1000, + }); - broadcastTunnelStatus(tunnelName, { + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + const initialNextRetryIn = Math.ceil(retryInterval / 1000); + let currentNextRetryIn = initialNextRetryIn; + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.WAITING, + retryCount: retryCount, + maxRetries: maxRetries, + nextRetryIn: currentNextRetryIn, + }); + + const countdownInterval = setInterval(() => { + currentNextRetryIn--; + if (currentNextRetryIn > 0) { + broadcastTunnelStatus(tunnelName, { connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true - }); - return; - } - - - if (retryExhaustedTunnels.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "Max retries already exhausted" - }); - return; - } - - if (activeRetryTimers.has(tunnelName)) { - return; - } - - if (shouldRetry && tunnelConfig) { - const maxRetries = tunnelConfig.maxRetries || 3; - const retryInterval = tunnelConfig.retryInterval || 5000; - - let retryCount = retryCounters.get(tunnelName) || 0; - retryCount = retryCount + 1; - - if (retryCount > maxRetries) { - tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`); - - retryExhaustedTunnels.add(tunnelName); - activeTunnels.delete(tunnelName); - retryCounters.delete(tunnelName); - - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - retryExhausted: true, - reason: `Max retries exhausted` - }); - return; + status: CONNECTION_STATES.WAITING, + retryCount: retryCount, + maxRetries: maxRetries, + nextRetryIn: currentNextRetryIn, + }); } + }, 1000); - retryCounters.set(tunnelName, retryCount); + countdownIntervals.set(tunnelName, countdownInterval); - if (retryCount <= maxRetries) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.RETRYING, - retryCount: retryCount, - maxRetries: maxRetries, - nextRetryIn: retryInterval / 1000 - }); + const timer = setTimeout(() => { + clearInterval(countdownInterval); + countdownIntervals.delete(tunnelName); + activeRetryTimers.delete(tunnelName); - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } - - const initialNextRetryIn = Math.ceil(retryInterval / 1000); - let currentNextRetryIn = initialNextRetryIn; - - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.WAITING, - retryCount: retryCount, - maxRetries: maxRetries, - nextRetryIn: currentNextRetryIn - }); - - const countdownInterval = setInterval(() => { - currentNextRetryIn--; - if (currentNextRetryIn > 0) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.WAITING, - retryCount: retryCount, - maxRetries: maxRetries, - nextRetryIn: currentNextRetryIn - }); - } - }, 1000); - - countdownIntervals.set(tunnelName, countdownInterval); - - const timer = setTimeout(() => { - clearInterval(countdownInterval); - countdownIntervals.delete(tunnelName); - activeRetryTimers.delete(tunnelName); - - if (!manualDisconnects.has(tunnelName)) { - activeTunnels.delete(tunnelName); - connectSSHTunnel(tunnelConfig, retryCount).catch(error => { - tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); - }); - } - }, retryInterval); - - activeRetryTimers.set(tunnelName, timer); + if (!manualDisconnects.has(tunnelName)) { + activeTunnels.delete(tunnelName); + connectSSHTunnel(tunnelConfig, retryCount).catch((error) => { + tunnelLogger.error( + `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }); } - } else { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED - }); + }, retryInterval); - activeTunnels.delete(tunnelName); + activeRetryTimers.set(tunnelName, timer); } + } else { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + }); + + activeTunnels.delete(tunnelName); + } } function setupPingInterval(tunnelName: string): void { - const pingKey = `${tunnelName}_ping`; - if (verificationTimers.has(pingKey)) { - clearInterval(verificationTimers.get(pingKey)!); + const pingKey = `${tunnelName}_ping`; + if (verificationTimers.has(pingKey)) { + clearInterval(verificationTimers.get(pingKey)!); + verificationTimers.delete(pingKey); + } + + const pingInterval = setInterval(() => { + const currentStatus = connectionStatus.get(tunnelName); + if (currentStatus?.status === CONNECTION_STATES.CONNECTED) { + if (!activeTunnels.has(tunnelName)) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + reason: "Tunnel connection lost", + }); + clearInterval(pingInterval); verificationTimers.delete(pingKey); + } + } else { + clearInterval(pingInterval); + verificationTimers.delete(pingKey); } + }, 120000); - const pingInterval = setInterval(() => { - const currentStatus = connectionStatus.get(tunnelName); - if (currentStatus?.status === CONNECTION_STATES.CONNECTED) { - if (!activeTunnels.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - reason: 'Tunnel connection lost' - }); - clearInterval(pingInterval); - verificationTimers.delete(pingKey); - } - } else { - clearInterval(pingInterval); - verificationTimers.delete(pingKey); - } - }, 120000); - - verificationTimers.set(pingKey, pingInterval); + verificationTimers.set(pingKey, pingInterval); } -async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): Promise { - const tunnelName = tunnelConfig.name; - const tunnelMarker = getTunnelMarker(tunnelName); +async function connectSSHTunnel( + tunnelConfig: TunnelConfig, + retryAttempt = 0, +): Promise { + const tunnelName = tunnelConfig.name; + const tunnelMarker = getTunnelMarker(tunnelName); - if (manualDisconnects.has(tunnelName)) { + if (manualDisconnects.has(tunnelName)) { + return; + } + + cleanupTunnelResources(tunnelName); + + if (retryAttempt === 0) { + retryExhaustedTunnels.delete(tunnelName); + retryCounters.delete(tunnelName); + } + + const currentStatus = connectionStatus.get(tunnelName); + if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.CONNECTING, + retryCount: retryAttempt > 0 ? retryAttempt : undefined, + }); + } + + if ( + !tunnelConfig || + !tunnelConfig.sourceIP || + !tunnelConfig.sourceUsername || + !tunnelConfig.sourceSSHPort + ) { + tunnelLogger.error("Invalid tunnel connection details", { + operation: "tunnel_connect", + tunnelName, + hasSourceIP: !!tunnelConfig?.sourceIP, + hasSourceUsername: !!tunnelConfig?.sourceUsername, + hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort, + }); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Missing required connection details", + }); + return; + } + + let resolvedSourceCredentials = { + password: tunnelConfig.sourcePassword, + sshKey: tunnelConfig.sourceSSHKey, + keyPassword: tunnelConfig.sourceKeyPassword, + keyType: tunnelConfig.sourceKeyType, + authMethod: tunnelConfig.sourceAuthMethod, + }; + + if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, tunnelConfig.sourceCredentialId), + eq(sshCredentials.userId, tunnelConfig.sourceUserId), + ), + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedSourceCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authMethod: credential.authType, + }; + } else { + tunnelLogger.warn("No source credentials found in database", { + operation: "tunnel_connect", + tunnelName, + credentialId: tunnelConfig.sourceCredentialId, + }); + } + } catch (error) { + tunnelLogger.warn("Failed to resolve source credentials from database", { + operation: "tunnel_connect", + tunnelName, + credentialId: tunnelConfig.sourceCredentialId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + let resolvedEndpointCredentials = { + password: tunnelConfig.endpointPassword, + sshKey: tunnelConfig.endpointSSHKey, + keyPassword: tunnelConfig.endpointKeyPassword, + keyType: tunnelConfig.endpointKeyType, + authMethod: tunnelConfig.endpointAuthMethod, + }; + + if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, tunnelConfig.endpointCredentialId), + eq(sshCredentials.userId, tunnelConfig.endpointUserId), + ), + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedEndpointCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authMethod: credential.authType, + }; + } else { + tunnelLogger.warn("No endpoint credentials found in database", { + operation: "tunnel_connect", + tunnelName, + credentialId: tunnelConfig.endpointCredentialId, + }); + } + } catch (error) { + tunnelLogger.warn( + `Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } else if (tunnelConfig.endpointCredentialId) { + tunnelLogger.warn("Missing userId for endpoint credential resolution", { + operation: "tunnel_connect", + tunnelName, + credentialId: tunnelConfig.endpointCredentialId, + hasUserId: !!tunnelConfig.endpointUserId, + }); + } + + const conn = new Client(); + + const connectionTimeout = setTimeout(() => { + if (conn) { + if (activeRetryTimers.has(tunnelName)) { return; + } + + try { + conn.end(); + } catch (e) {} + + activeTunnels.delete(tunnelName); + + if (!activeRetryTimers.has(tunnelName)) { + handleDisconnect( + tunnelName, + tunnelConfig, + !manualDisconnects.has(tunnelName), + ); + } + } + }, 60000); + + conn.on("error", (err) => { + clearTimeout(connectionTimeout); + tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`); + + if (activeRetryTimers.has(tunnelName)) { + return; } - cleanupTunnelResources(tunnelName); + const errorType = classifyError(err.message); - if (retryAttempt === 0) { - retryExhaustedTunnels.delete(tunnelName); - retryCounters.delete(tunnelName); + if (!manualDisconnects.has(tunnelName)) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + errorType: errorType, + reason: err.message, + }); } - const currentStatus = connectionStatus.get(tunnelName); - if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { + activeTunnels.delete(tunnelName); + + const shouldNotRetry = + errorType === "AUTHENTICATION_FAILED" || + errorType === "CONNECTION_FAILED" || + manualDisconnects.has(tunnelName); + + handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); + }); + + conn.on("close", () => { + clearTimeout(connectionTimeout); + + if (activeRetryTimers.has(tunnelName)) { + return; + } + + if (!manualDisconnects.has(tunnelName)) { + const currentStatus = connectionStatus.get(tunnelName); + if (!currentStatus || currentStatus.status !== CONNECTION_STATES.FAILED) { broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.CONNECTING, - retryCount: retryAttempt > 0 ? retryAttempt : undefined + connected: false, + status: CONNECTION_STATES.DISCONNECTED, }); + } + + if (!activeRetryTimers.has(tunnelName)) { + handleDisconnect( + tunnelName, + tunnelConfig, + !manualDisconnects.has(tunnelName), + ); + } + } + }); + + conn.on("ready", () => { + clearTimeout(connectionTimeout); + + const isAlreadyVerifying = tunnelVerifications.has(tunnelName); + if (isAlreadyVerifying) { + return; } - if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) { - tunnelLogger.error('Invalid tunnel connection details', { - operation: 'tunnel_connect', - tunnelName, - hasSourceIP: !!tunnelConfig?.sourceIP, - hasSourceUsername: !!tunnelConfig?.sourceUsername, - hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort - }); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "Missing required connection details" - }); - return; + let tunnelCmd: string; + if ( + resolvedEndpointCredentials.authMethod === "key" && + resolvedEndpointCredentials.sshKey + ) { + const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; + tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; + } else { + tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; } - let resolvedSourceCredentials = { - password: tunnelConfig.sourcePassword, - sshKey: tunnelConfig.sourceSSHKey, - keyPassword: tunnelConfig.sourceKeyPassword, - keyType: tunnelConfig.sourceKeyType, - authMethod: tunnelConfig.sourceAuthMethod - }; + conn.exec(tunnelCmd, (err, stream) => { + if (err) { + tunnelLogger.error( + `Connection error for '${tunnelName}': ${err.message}`, + ); - if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, tunnelConfig.sourceCredentialId), - eq(sshCredentials.userId, tunnelConfig.sourceUserId) - )); + conn.end(); - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedSourceCredentials = { - password: credential.password, - sshKey: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authMethod: credential.authType - }; - } else { - tunnelLogger.warn('No source credentials found in database', { - operation: 'tunnel_connect', - tunnelName, - credentialId: tunnelConfig.sourceCredentialId - }); - } - } catch (error) { - tunnelLogger.warn('Failed to resolve source credentials from database', { - operation: 'tunnel_connect', - tunnelName, - credentialId: tunnelConfig.sourceCredentialId, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - - let resolvedEndpointCredentials = { - password: tunnelConfig.endpointPassword, - sshKey: tunnelConfig.endpointSSHKey, - keyPassword: tunnelConfig.endpointKeyPassword, - keyType: tunnelConfig.endpointKeyType, - authMethod: tunnelConfig.endpointAuthMethod - }; - - if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, tunnelConfig.endpointCredentialId), - eq(sshCredentials.userId, tunnelConfig.endpointUserId) - )); - - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedEndpointCredentials = { - password: credential.password, - sshKey: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authMethod: credential.authType - }; - } else { - tunnelLogger.warn('No endpoint credentials found in database', { - operation: 'tunnel_connect', - tunnelName, - credentialId: tunnelConfig.endpointCredentialId - }); - } - } catch (error) { - tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } else if (tunnelConfig.endpointCredentialId) { - tunnelLogger.warn('Missing userId for endpoint credential resolution', { - operation: 'tunnel_connect', - tunnelName, - credentialId: tunnelConfig.endpointCredentialId, - hasUserId: !!tunnelConfig.endpointUserId - }); - } - - const conn = new Client(); - - const connectionTimeout = setTimeout(() => { - if (conn) { - if (activeRetryTimers.has(tunnelName)) { - return; - } - - try { - conn.end(); - } catch (e) { - } - - activeTunnels.delete(tunnelName); - - if (!activeRetryTimers.has(tunnelName)) { - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - } - } - }, 60000); - - conn.on("error", (err) => { - clearTimeout(connectionTimeout); - tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`); - - if (activeRetryTimers.has(tunnelName)) { - return; - } + activeTunnels.delete(tunnelName); const errorType = classifyError(err.message); + const shouldNotRetry = + errorType === "AUTHENTICATION_FAILED" || + errorType === "CONNECTION_FAILED"; - if (!manualDisconnects.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - errorType: errorType, - reason: err.message - }); + handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); + return; + } + + activeTunnels.set(tunnelName, conn); + + setTimeout(() => { + if ( + !manualDisconnects.has(tunnelName) && + activeTunnels.has(tunnelName) + ) { + broadcastTunnelStatus(tunnelName, { + connected: true, + status: CONNECTION_STATES.CONNECTED, + }); + setupPingInterval(tunnelName); + } + }, 2000); + + stream.on("close", (code: number) => { + if (activeRetryTimers.has(tunnelName)) { + return; } activeTunnels.delete(tunnelName); - const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' || - errorType === 'CONNECTION_FAILED' || - manualDisconnects.has(tunnelName); - - - handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); - }); - - conn.on("close", () => { - clearTimeout(connectionTimeout); - - if (activeRetryTimers.has(tunnelName)) { - return; + if (tunnelVerifications.has(tunnelName)) { + try { + const verification = tunnelVerifications.get(tunnelName); + if (verification?.timeout) clearTimeout(verification.timeout); + verification?.conn.end(); + } catch (e) {} + tunnelVerifications.delete(tunnelName); } - if (!manualDisconnects.has(tunnelName)) { - const currentStatus = connectionStatus.get(tunnelName); - if (!currentStatus || currentStatus.status !== CONNECTION_STATES.FAILED) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED - }); - } + const isLikelyRemoteClosure = code === 255; - if (!activeRetryTimers.has(tunnelName)) { - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - } - } - }); - - conn.on("ready", () => { - clearTimeout(connectionTimeout); - - const isAlreadyVerifying = tunnelVerifications.has(tunnelName); - if (isAlreadyVerifying) { - return; + if (isLikelyRemoteClosure && retryExhaustedTunnels.has(tunnelName)) { + retryExhaustedTunnels.delete(tunnelName); } - let tunnelCmd: string; - if (resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.sshKey) { - const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; - tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; - } else { - tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; - } - - conn.exec(tunnelCmd, (err, stream) => { - if (err) { - tunnelLogger.error(`Connection error for '${tunnelName}': ${err.message}`); - - conn.end(); - - activeTunnels.delete(tunnelName); - - const errorType = classifyError(err.message); - const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' || - errorType === 'CONNECTION_FAILED'; - - handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); - return; - } - - activeTunnels.set(tunnelName, conn); - - setTimeout(() => { - if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: true, - status: CONNECTION_STATES.CONNECTED - }); - setupPingInterval(tunnelName); - } - }, 2000); - - stream.on("close", (code: number) => { - if (activeRetryTimers.has(tunnelName)) { - return; - } - - activeTunnels.delete(tunnelName); - - if (tunnelVerifications.has(tunnelName)) { - try { - const verification = tunnelVerifications.get(tunnelName); - if (verification?.timeout) clearTimeout(verification.timeout); - verification?.conn.end(); - } catch (e) { - } - tunnelVerifications.delete(tunnelName); - } - - const isLikelyRemoteClosure = code === 255; - - if (isLikelyRemoteClosure && retryExhaustedTunnels.has(tunnelName)) { - retryExhaustedTunnels.delete(tunnelName); - } - - if (!manualDisconnects.has(tunnelName) && code !== 0 && code !== undefined) { - if (retryExhaustedTunnels.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "Max retries exhausted" - }); - } else { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: isLikelyRemoteClosure ? "Connection closed by remote host" : "Connection closed unexpectedly" - }); - } - } - - if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) { - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - } else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) { - retryExhaustedTunnels.delete(tunnelName); - retryCounters.delete(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, true); - } - }); - - stream.stdout?.on("data", (data: Buffer) => { - }); - - stream.on("error", (err: Error) => { - }); - - stream.stderr.on("data", (data) => { - const errorMsg = data.toString().trim(); - }); - }); - }); - - const connOptions: any = { - host: tunnelConfig.sourceIP, - port: tunnelConfig.sourceSSHPort, - username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 15000, - algorithms: { - kex: [ - 'diffie-hellman-group14-sha256', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521' - ], - cipher: [ - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm@openssh.com', - 'aes256-gcm@openssh.com', - 'aes128-cbc', - 'aes192-cbc', - 'aes256-cbc', - '3des-cbc' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - } - }; - - if (resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey) { - if (!resolvedSourceCredentials.sshKey.includes('-----BEGIN') || !resolvedSourceCredentials.sshKey.includes('-----END')) { - tunnelLogger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`); + if ( + !manualDisconnects.has(tunnelName) && + code !== 0 && + code !== undefined + ) { + if (retryExhaustedTunnels.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "Invalid SSH key format" + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Max retries exhausted", }); - return; + } else { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: isLikelyRemoteClosure + ? "Connection closed by remote host" + : "Connection closed unexpectedly", + }); + } } - const cleanKey = resolvedSourceCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); - if (resolvedSourceCredentials.keyPassword) { - connOptions.passphrase = resolvedSourceCredentials.keyPassword; + if ( + !activeRetryTimers.has(tunnelName) && + !retryExhaustedTunnels.has(tunnelName) + ) { + handleDisconnect( + tunnelName, + tunnelConfig, + !manualDisconnects.has(tunnelName), + ); + } else if ( + retryExhaustedTunnels.has(tunnelName) && + isLikelyRemoteClosure + ) { + retryExhaustedTunnels.delete(tunnelName); + retryCounters.delete(tunnelName); + handleDisconnect(tunnelName, tunnelConfig, true); } - if (resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== 'auto') { - connOptions.privateKeyType = resolvedSourceCredentials.keyType; - } - } else if (resolvedSourceCredentials.authMethod === "key") { - tunnelLogger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "SSH key authentication requested but no key provided" - }); - return; - } else { - connOptions.password = resolvedSourceCredentials.password; + }); + + stream.stdout?.on("data", (data: Buffer) => {}); + + stream.on("error", (err: Error) => {}); + + stream.stderr.on("data", (data) => { + const errorMsg = data.toString().trim(); + }); + }); + }); + + const connOptions: any = { + host: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 15000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + + if ( + resolvedSourceCredentials.authMethod === "key" && + resolvedSourceCredentials.sshKey + ) { + if ( + !resolvedSourceCredentials.sshKey.includes("-----BEGIN") || + !resolvedSourceCredentials.sshKey.includes("-----END") + ) { + tunnelLogger.error( + `Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`, + ); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Invalid SSH key format", + }); + return; } - const finalStatus = connectionStatus.get(tunnelName); - if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.CONNECTING, - retryCount: retryAttempt > 0 ? retryAttempt : undefined - }); + const cleanKey = resolvedSourceCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connOptions.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedSourceCredentials.keyPassword) { + connOptions.passphrase = resolvedSourceCredentials.keyPassword; } + if ( + resolvedSourceCredentials.keyType && + resolvedSourceCredentials.keyType !== "auto" + ) { + connOptions.privateKeyType = resolvedSourceCredentials.keyType; + } + } else if (resolvedSourceCredentials.authMethod === "key") { + tunnelLogger.error( + `SSH key authentication requested but no key provided for tunnel '${tunnelName}'`, + ); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "SSH key authentication requested but no key provided", + }); + return; + } else { + connOptions.password = resolvedSourceCredentials.password; + } - conn.connect(connOptions); + const finalStatus = connectionStatus.get(tunnelName); + if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.CONNECTING, + retryCount: retryAttempt > 0 ? retryAttempt : undefined, + }); + } + + conn.connect(connOptions); } -function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) { - const tunnelMarker = getTunnelMarker(tunnelName); - const conn = new Client(); - const connOptions: any = { - host: tunnelConfig.sourceIP, - port: tunnelConfig.sourceSSHPort, - username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 15000, - algorithms: { - kex: [ - 'diffie-hellman-group14-sha256', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521' - ], - cipher: [ - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm@openssh.com', - 'aes256-gcm@openssh.com', - 'aes128-cbc', - 'aes192-cbc', - 'aes256-cbc', - '3des-cbc' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - } - }; - if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) { - callback(new Error('Invalid SSH key format')); - return; - } - - const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); - if (tunnelConfig.sourceKeyPassword) { - connOptions.passphrase = tunnelConfig.sourceKeyPassword; - } - if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { - connOptions.privateKeyType = tunnelConfig.sourceKeyType; - } - } else { - connOptions.password = tunnelConfig.sourcePassword; +function killRemoteTunnelByMarker( + tunnelConfig: TunnelConfig, + tunnelName: string, + callback: (err?: Error) => void, +) { + const tunnelMarker = getTunnelMarker(tunnelName); + const conn = new Client(); + const connOptions: any = { + host: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 15000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { + if ( + !tunnelConfig.sourceSSHKey.includes("-----BEGIN") || + !tunnelConfig.sourceSSHKey.includes("-----END") + ) { + callback(new Error("Invalid SSH key format")); + return; } - conn.on('ready', () => { - const killCmd = `pkill -f '${tunnelMarker}'`; - conn.exec(killCmd, (err, stream) => { - if (err) { - conn.end(); - callback(err); - return; - } - stream.on('close', () => { - conn.end(); - callback(); - }); - stream.on('data', () => { - }); - stream.stderr.on('data', () => { - }); - }); - }); - conn.on('error', (err) => { + + const cleanKey = tunnelConfig.sourceSSHKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connOptions.privateKey = Buffer.from(cleanKey, "utf8"); + if (tunnelConfig.sourceKeyPassword) { + connOptions.passphrase = tunnelConfig.sourceKeyPassword; + } + if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== "auto") { + connOptions.privateKeyType = tunnelConfig.sourceKeyType; + } + } else { + connOptions.password = tunnelConfig.sourcePassword; + } + conn.on("ready", () => { + const killCmd = `pkill -f '${tunnelMarker}'`; + conn.exec(killCmd, (err, stream) => { + if (err) { + conn.end(); callback(err); + return; + } + stream.on("close", () => { + conn.end(); + callback(); + }); + stream.on("data", () => {}); + stream.stderr.on("data", () => {}); }); - conn.connect(connOptions); + }); + conn.on("error", (err) => { + callback(err); + }); + conn.connect(connOptions); } -app.get('/ssh/tunnel/status', (req, res) => { - res.json(getAllTunnelStatus()); +app.get("/ssh/tunnel/status", (req, res) => { + res.json(getAllTunnelStatus()); }); -app.get('/ssh/tunnel/status/:tunnelName', (req, res) => { - const {tunnelName} = req.params; - const status = connectionStatus.get(tunnelName); +app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { + const { tunnelName } = req.params; + const status = connectionStatus.get(tunnelName); - if (!status) { - return res.status(404).json({error: 'Tunnel not found'}); - } + if (!status) { + return res.status(404).json({ error: "Tunnel not found" }); + } - res.json({name: tunnelName, status}); + res.json({ name: tunnelName, status }); }); -app.post('/ssh/tunnel/connect', (req, res) => { - const tunnelConfig: TunnelConfig = req.body; +app.post("/ssh/tunnel/connect", (req, res) => { + const tunnelConfig: TunnelConfig = req.body; - if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({error: 'Invalid tunnel configuration'}); - } + if (!tunnelConfig || !tunnelConfig.name) { + return res.status(400).json({ error: "Invalid tunnel configuration" }); + } - const tunnelName = tunnelConfig.name; + const tunnelName = tunnelConfig.name; + manualDisconnects.delete(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + tunnelConfigs.set(tunnelName, tunnelConfig); + + connectSSHTunnel(tunnelConfig, 0).catch((error) => { + tunnelLogger.error( + `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }); + + res.json({ message: "Connection request received", tunnelName }); +}); + +app.post("/ssh/tunnel/disconnect", (req, res) => { + const { tunnelName } = req.body; + + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } + + manualDisconnects.add(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + setTimeout(() => { manualDisconnects.delete(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + }, 5000); - tunnelConfigs.set(tunnelName, tunnelConfig); - - connectSSHTunnel(tunnelConfig, 0).catch(error => { - tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); - }); - - res.json({message: 'Connection request received', tunnelName}); + res.json({ message: "Disconnect request received", tunnelName }); }); -app.post('/ssh/tunnel/disconnect', (req, res) => { - const {tunnelName} = req.body; +app.post("/ssh/tunnel/cancel", (req, res) => { + const { tunnelName } = req.body; - if (!tunnelName) { - return res.status(400).json({error: 'Tunnel name required'}); - } + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } - manualDisconnects.add(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true - }); + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); - res.json({message: 'Disconnect request received', tunnelName}); -}); + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); -app.post('/ssh/tunnel/cancel', (req, res) => { - const {tunnelName} = req.body; - - if (!tunnelName) { - return res.status(400).json({error: 'Tunnel name required'}); - } - - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); - - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } - - if (countdownIntervals.has(tunnelName)) { - clearInterval(countdownIntervals.get(tunnelName)!); - countdownIntervals.delete(tunnelName); - } - - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true - }); - - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); - - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); - - res.json({message: 'Cancel request received', tunnelName}); + res.json({ message: "Cancel request received", tunnelName }); }); async function initializeAutoStartTunnels(): Promise { - try { - const response = await axios.get('http://localhost:8081/ssh/db/host/internal', { - headers: { - 'Content-Type': 'application/json', - 'X-Internal-Request': '1' - } - }); - - const hosts: SSHHost[] = response.data || []; - const autoStartTunnels: TunnelConfig[] = []; - - for (const host of hosts) { - if (host.enableTunnel && host.tunnelConnections) { - for (const tunnelConnection of host.tunnelConnections) { - if (tunnelConnection.autoStart) { - const endpointHost = hosts.find(h => - h.name === tunnelConnection.endpointHost || - `${h.username}@${h.ip}` === tunnelConnection.endpointHost - ); - - if (endpointHost) { - const tunnelConfig: TunnelConfig = { - name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, - hostName: host.name || `${host.username}@${host.ip}`, - sourceIP: host.ip, - sourceSSHPort: host.port, - sourceUsername: host.username, - sourcePassword: host.password, - sourceAuthMethod: host.authType, - sourceSSHKey: host.key, - sourceKeyPassword: host.keyPassword, - sourceKeyType: host.keyType, - endpointIP: endpointHost.ip, - endpointSSHPort: endpointHost.port, - endpointUsername: endpointHost.username, - endpointPassword: endpointHost.password, - endpointAuthMethod: endpointHost.authType, - endpointSSHKey: endpointHost.key, - endpointKeyPassword: endpointHost.keyPassword, - endpointKeyType: endpointHost.keyType, - sourcePort: tunnelConnection.sourcePort, - endpointPort: tunnelConnection.endpointPort, - maxRetries: tunnelConnection.maxRetries, - retryInterval: tunnelConnection.retryInterval * 1000, - autoStart: tunnelConnection.autoStart, - isPinned: host.pin - }; - - autoStartTunnels.push(tunnelConfig); - } - } - } + try { + const response = await axios.get( + "http://localhost:8081/ssh/db/host/internal", + { + headers: { + "Content-Type": "application/json", + "X-Internal-Request": "1", + }, + }, + ); + + const hosts: SSHHost[] = response.data || []; + const autoStartTunnels: TunnelConfig[] = []; + + for (const host of hosts) { + if (host.enableTunnel && host.tunnelConnections) { + for (const tunnelConnection of host.tunnelConnections) { + if (tunnelConnection.autoStart) { + const endpointHost = hosts.find( + (h) => + h.name === tunnelConnection.endpointHost || + `${h.username}@${h.ip}` === tunnelConnection.endpointHost, + ); + + if (endpointHost) { + const tunnelConfig: TunnelConfig = { + name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, + hostName: host.name || `${host.username}@${host.ip}`, + sourceIP: host.ip, + sourceSSHPort: host.port, + sourceUsername: host.username, + sourcePassword: host.password, + sourceAuthMethod: host.authType, + sourceSSHKey: host.key, + sourceKeyPassword: host.keyPassword, + sourceKeyType: host.keyType, + endpointIP: endpointHost.ip, + endpointSSHPort: endpointHost.port, + endpointUsername: endpointHost.username, + endpointPassword: endpointHost.password, + endpointAuthMethod: endpointHost.authType, + endpointSSHKey: endpointHost.key, + endpointKeyPassword: endpointHost.keyPassword, + endpointKeyType: endpointHost.keyType, + sourcePort: tunnelConnection.sourcePort, + endpointPort: tunnelConnection.endpointPort, + maxRetries: tunnelConnection.maxRetries, + retryInterval: tunnelConnection.retryInterval * 1000, + autoStart: tunnelConnection.autoStart, + isPinned: host.pin, + }; + + autoStartTunnels.push(tunnelConfig); } + } } - - tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`); - - for (const tunnelConfig of autoStartTunnels) { - tunnelConfigs.set(tunnelConfig.name, tunnelConfig); - - setTimeout(() => { - connectSSHTunnel(tunnelConfig, 0).catch(error => { - tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); - }); - }, 1000); - } - } catch (error: any) { - tunnelLogger.error('Failed to initialize auto-start tunnels:', error.message); + } } + + tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`); + + for (const tunnelConfig of autoStartTunnels) { + tunnelConfigs.set(tunnelConfig.name, tunnelConfig); + + setTimeout(() => { + connectSSHTunnel(tunnelConfig, 0).catch((error) => { + tunnelLogger.error( + `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }); + }, 1000); + } + } catch (error: any) { + tunnelLogger.error( + "Failed to initialize auto-start tunnels:", + error.message, + ); + } } const PORT = 8083; app.listen(PORT, () => { - tunnelLogger.success('SSH Tunnel API server started', {operation: 'server_start', port: PORT}); - setTimeout(() => { - initializeAutoStartTunnels(); - }, 2000); -}); \ No newline at end of file + tunnelLogger.success("SSH Tunnel API server started", { + operation: "server_start", + port: PORT, + }); + setTimeout(() => { + initializeAutoStartTunnels(); + }, 2000); +}); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index ec39cf0b..83caf7ed 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -1,52 +1,65 @@ // npx tsc -p tsconfig.node.json // node ./dist/backend/starter.js -import './database/database.js' -import './ssh/terminal.js'; -import './ssh/tunnel.js'; -import './ssh/file-manager.js'; -import './ssh/server-stats.js'; -import { systemLogger, versionLogger } from './utils/logger.js'; -import 'dotenv/config'; +import "./database/database.js"; +import "./ssh/terminal.js"; +import "./ssh/tunnel.js"; +import "./ssh/file-manager.js"; +import "./ssh/server-stats.js"; +import { systemLogger, versionLogger } from "./utils/logger.js"; +import "dotenv/config"; (async () => { - try { - const version = process.env.VERSION || 'unknown'; - versionLogger.info(`Termix Backend starting - Version: ${version}`, { - operation: 'startup', - version: version - }); - - systemLogger.info("Initializing backend services...", { operation: 'startup' }); - - systemLogger.success("All backend services initialized successfully", { - operation: 'startup_complete', - services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'], - version: version - }); + try { + const version = process.env.VERSION || "unknown"; + versionLogger.info(`Termix Backend starting - Version: ${version}`, { + operation: "startup", + version: version, + }); - process.on('SIGINT', () => { - systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' }); - process.exit(0); - }); + systemLogger.info("Initializing backend services...", { + operation: "startup", + }); - process.on('SIGTERM', () => { - systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' }); - process.exit(0); - }); + systemLogger.success("All backend services initialized successfully", { + operation: "startup_complete", + services: ["database", "terminal", "tunnel", "file_manager", "stats"], + version: version, + }); - process.on('uncaughtException', (error) => { - systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' }); - process.exit(1); - }); + process.on("SIGINT", () => { + systemLogger.info( + "Received SIGINT signal, initiating graceful shutdown...", + { operation: "shutdown" }, + ); + process.exit(0); + }); - process.on('unhandledRejection', (reason, promise) => { - systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' }); - process.exit(1); - }); + process.on("SIGTERM", () => { + systemLogger.info( + "Received SIGTERM signal, initiating graceful shutdown...", + { operation: "shutdown" }, + ); + process.exit(0); + }); - } catch (error) { - systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' }); - process.exit(1); - } -})(); \ No newline at end of file + process.on("uncaughtException", (error) => { + systemLogger.error("Uncaught exception occurred", error, { + operation: "error_handling", + }); + process.exit(1); + }); + + process.on("unhandledRejection", (reason, promise) => { + systemLogger.error("Unhandled promise rejection", reason, { + operation: "error_handling", + }); + process.exit(1); + }); + } catch (error) { + systemLogger.error("Failed to initialize backend services", error, { + operation: "startup_failed", + }); + process.exit(1); + } +})(); diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index fb60b639..598e10a8 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -1,158 +1,174 @@ -import chalk from 'chalk'; +import chalk from "chalk"; -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; +export type LogLevel = "debug" | "info" | "warn" | "error" | "success"; export interface LogContext { - service?: string; - operation?: string; - userId?: string; - hostId?: number; - tunnelName?: string; - sessionId?: string; - requestId?: string; - duration?: number; - [key: string]: any; + service?: string; + operation?: string; + userId?: string; + hostId?: number; + tunnelName?: string; + sessionId?: string; + requestId?: string; + duration?: number; + [key: string]: any; } class Logger { - private serviceName: string; - private serviceIcon: string; - private serviceColor: string; + private serviceName: string; + private serviceIcon: string; + private serviceColor: string; - constructor(serviceName: string, serviceIcon: string, serviceColor: string) { - this.serviceName = serviceName; - this.serviceIcon = serviceIcon; - this.serviceColor = serviceColor; + constructor(serviceName: string, serviceIcon: string, serviceColor: string) { + this.serviceName = serviceName; + this.serviceIcon = serviceIcon; + this.serviceColor = serviceColor; + } + + private getTimeStamp(): string { + return chalk.gray(`[${new Date().toLocaleTimeString()}]`); + } + + private formatMessage( + level: LogLevel, + message: string, + context?: LogContext, + ): string { + const timestamp = this.getTimeStamp(); + const levelColor = this.getLevelColor(level); + const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`); + const levelTag = levelColor(`[${level.toUpperCase()}]`); + + let contextStr = ""; + if (context) { + const contextParts = []; + if (context.operation) contextParts.push(`op:${context.operation}`); + if (context.userId) contextParts.push(`user:${context.userId}`); + if (context.hostId) contextParts.push(`host:${context.hostId}`); + if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); + if (context.sessionId) contextParts.push(`session:${context.sessionId}`); + if (context.requestId) contextParts.push(`req:${context.requestId}`); + if (context.duration) contextParts.push(`duration:${context.duration}ms`); + + if (contextParts.length > 0) { + contextStr = chalk.gray(` [${contextParts.join(",")}]`); + } } - private getTimeStamp(): string { - return chalk.gray(`[${new Date().toLocaleTimeString()}]`); - } + return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; + } - private formatMessage(level: LogLevel, message: string, context?: LogContext): string { - const timestamp = this.getTimeStamp(); - const levelColor = this.getLevelColor(level); - const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`); - const levelTag = levelColor(`[${level.toUpperCase()}]`); - - let contextStr = ''; - if (context) { - const contextParts = []; - if (context.operation) contextParts.push(`op:${context.operation}`); - if (context.userId) contextParts.push(`user:${context.userId}`); - if (context.hostId) contextParts.push(`host:${context.hostId}`); - if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); - if (context.sessionId) contextParts.push(`session:${context.sessionId}`); - if (context.requestId) contextParts.push(`req:${context.requestId}`); - if (context.duration) contextParts.push(`duration:${context.duration}ms`); - - if (contextParts.length > 0) { - contextStr = chalk.gray(` [${contextParts.join(',')}]`); - } - } - - return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; + private getLevelColor(level: LogLevel): chalk.Chalk { + switch (level) { + case "debug": + return chalk.magenta; + case "info": + return chalk.cyan; + case "warn": + return chalk.yellow; + case "error": + return chalk.redBright; + case "success": + return chalk.greenBright; + default: + return chalk.white; } + } - private getLevelColor(level: LogLevel): chalk.Chalk { - switch (level) { - case 'debug': return chalk.magenta; - case 'info': return chalk.cyan; - case 'warn': return chalk.yellow; - case 'error': return chalk.redBright; - case 'success': return chalk.greenBright; - default: return chalk.white; - } + private shouldLog(level: LogLevel): boolean { + if (level === "debug" && process.env.NODE_ENV === "production") { + return false; } + return true; + } - private shouldLog(level: LogLevel): boolean { - if (level === 'debug' && process.env.NODE_ENV === 'production') { - return false; - } - return true; - } + debug(message: string, context?: LogContext): void { + if (!this.shouldLog("debug")) return; + console.debug(this.formatMessage("debug", message, context)); + } - debug(message: string, context?: LogContext): void { - if (!this.shouldLog('debug')) return; - console.debug(this.formatMessage('debug', message, context)); - } + info(message: string, context?: LogContext): void { + if (!this.shouldLog("info")) return; + console.log(this.formatMessage("info", message, context)); + } - info(message: string, context?: LogContext): void { - if (!this.shouldLog('info')) return; - console.log(this.formatMessage('info', message, context)); - } + warn(message: string, context?: LogContext): void { + if (!this.shouldLog("warn")) return; + console.warn(this.formatMessage("warn", message, context)); + } - warn(message: string, context?: LogContext): void { - if (!this.shouldLog('warn')) return; - console.warn(this.formatMessage('warn', message, context)); + error(message: string, error?: unknown, context?: LogContext): void { + if (!this.shouldLog("error")) return; + console.error(this.formatMessage("error", message, context)); + if (error) { + console.error(error); } + } - error(message: string, error?: unknown, context?: LogContext): void { - if (!this.shouldLog('error')) return; - console.error(this.formatMessage('error', message, context)); - if (error) { - console.error(error); - } - } + success(message: string, context?: LogContext): void { + if (!this.shouldLog("success")) return; + console.log(this.formatMessage("success", message, context)); + } - success(message: string, context?: LogContext): void { - if (!this.shouldLog('success')) return; - console.log(this.formatMessage('success', message, context)); - } + auth(message: string, context?: LogContext): void { + this.info(`AUTH: ${message}`, { ...context, operation: "auth" }); + } - auth(message: string, context?: LogContext): void { - this.info(`AUTH: ${message}`, { ...context, operation: 'auth' }); - } + db(message: string, context?: LogContext): void { + this.info(`DB: ${message}`, { ...context, operation: "database" }); + } - db(message: string, context?: LogContext): void { - this.info(`DB: ${message}`, { ...context, operation: 'database' }); - } + ssh(message: string, context?: LogContext): void { + this.info(`SSH: ${message}`, { ...context, operation: "ssh" }); + } - ssh(message: string, context?: LogContext): void { - this.info(`SSH: ${message}`, { ...context, operation: 'ssh' }); - } + tunnel(message: string, context?: LogContext): void { + this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" }); + } - tunnel(message: string, context?: LogContext): void { - this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' }); - } + file(message: string, context?: LogContext): void { + this.info(`FILE: ${message}`, { ...context, operation: "file" }); + } - file(message: string, context?: LogContext): void { - this.info(`FILE: ${message}`, { ...context, operation: 'file' }); - } + api(message: string, context?: LogContext): void { + this.info(`API: ${message}`, { ...context, operation: "api" }); + } - api(message: string, context?: LogContext): void { - this.info(`API: ${message}`, { ...context, operation: 'api' }); - } + request(message: string, context?: LogContext): void { + this.info(`REQUEST: ${message}`, { ...context, operation: "request" }); + } - request(message: string, context?: LogContext): void { - this.info(`REQUEST: ${message}`, { ...context, operation: 'request' }); - } + response(message: string, context?: LogContext): void { + this.info(`RESPONSE: ${message}`, { ...context, operation: "response" }); + } - response(message: string, context?: LogContext): void { - this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' }); - } + connection(message: string, context?: LogContext): void { + this.info(`CONNECTION: ${message}`, { + ...context, + operation: "connection", + }); + } - connection(message: string, context?: LogContext): void { - this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' }); - } + disconnect(message: string, context?: LogContext): void { + this.info(`DISCONNECT: ${message}`, { + ...context, + operation: "disconnect", + }); + } - disconnect(message: string, context?: LogContext): void { - this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' }); - } - - retry(message: string, context?: LogContext): void { - this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' }); - } + retry(message: string, context?: LogContext): void { + this.warn(`RETRY: ${message}`, { ...context, operation: "retry" }); + } } -export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1'); -export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9'); -export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7'); -export const fileLogger = new Logger('FILE', '📁', '#f59e0b'); -export const statsLogger = new Logger('STATS', '📊', '#22c55e'); -export const apiLogger = new Logger('API', '🌐', '#3b82f6'); -export const authLogger = new Logger('AUTH', '🔐', '#ef4444'); -export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6'); -export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6'); +export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1"); +export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9"); +export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7"); +export const fileLogger = new Logger("FILE", "📁", "#f59e0b"); +export const statsLogger = new Logger("STATS", "📊", "#22c55e"); +export const apiLogger = new Logger("API", "🌐", "#3b82f6"); +export const authLogger = new Logger("AUTH", "🔐", "#ef4444"); +export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); +export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const logger = systemLogger; diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 4977fd23..e18440d7 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,73 +1,73 @@ -import {createContext, useContext, useEffect, useState} from "react" +import { createContext, useContext, useEffect, useState } from "react"; -type Theme = "dark" | "light" | "system" +type Theme = "dark" | "light" | "system"; type ThemeProviderProps = { - children: React.ReactNode - defaultTheme?: Theme - storageKey?: string -} + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; type ThemeProviderState = { - theme: Theme - setTheme: (theme: Theme) => void -} + theme: Theme; + setTheme: (theme: Theme) => void; +}; const initialState: ThemeProviderState = { - theme: "system", - setTheme: () => null, -} + theme: "system", + setTheme: () => null, +}; -const ThemeProviderContext = createContext(initialState) +const ThemeProviderContext = createContext(initialState); export function ThemeProvider({ - children, - defaultTheme = "system", - storageKey = "vite-ui-theme", - ...props - }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme - ) + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + ); - useEffect(() => { - const root = window.document.documentElement + useEffect(() => { + const root = window.document.documentElement; - root.classList.remove("light", "dark") + root.classList.remove("light", "dark"); - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light" + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; - root.classList.add(systemTheme) - return - } - - root.classList.add(theme) - }, [theme]) - - const value = { - theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme) - setTheme(theme) - }, + root.classList.add(systemTheme); + return; } - return ( - - {children} - - ) + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); } export const useTheme = () => { - const context = useContext(ThemeProviderContext) + const context = useContext(ThemeProviderContext); - if (context === undefined) - throw new Error("useTheme must be used within a ThemeProvider") + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); - return context -} \ No newline at end of file + return context; +}; diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index d21b65f7..720bff51 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -1,13 +1,13 @@ -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Accordion({ ...props }: React.ComponentProps) { - return + return ; } function AccordionItem({ @@ -20,7 +20,7 @@ function AccordionItem({ className={cn("border-b last:border-b-0", className)} {...props} /> - ) + ); } function AccordionTrigger({ @@ -34,7 +34,7 @@ function AccordionTrigger({ data-slot="accordion-trigger" className={cn( "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", - className + className, )} {...props} > @@ -42,7 +42,7 @@ function AccordionTrigger({ - ) + ); } function AccordionContent({ @@ -58,7 +58,7 @@ function AccordionContent({ >
{children}
- ) + ); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index eda4eee8..2879e585 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", @@ -16,8 +16,8 @@ const alertVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); function Alert({ className, @@ -31,7 +31,7 @@ function Alert({ className={cn(alertVariants({ variant }), className)} {...props} /> - ) + ); } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { data-slot="alert-title" className={cn( "col-start-2 font-medium tracking-tight whitespace-normal break-words", - className + className, )} {...props} /> - ) + ); } function AlertDescription({ @@ -56,11 +56,11 @@ function AlertDescription({ data-slot="alert-description" className={cn( "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", - className + className, )} {...props} /> - ) + ); } -export { Alert, AlertTitle, AlertDescription } +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 02054139..46f988c2 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", @@ -22,8 +22,8 @@ const badgeVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); function Badge({ className, @@ -32,7 +32,7 @@ function Badge({ ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" + const Comp = asChild ? Slot : "span"; return ( - ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx index 154b1562..46f8cda3 100644 --- a/src/components/ui/button-group.tsx +++ b/src/components/ui/button-group.tsx @@ -1,37 +1,37 @@ -import { Children, ReactElement, cloneElement, isValidElement } from 'react'; +import { Children, ReactElement, cloneElement, isValidElement } from "react"; -import { type ButtonProps } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; +import { type ButtonProps } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; interface ButtonGroupProps { className?: string; - orientation?: 'horizontal' | 'vertical'; + orientation?: "horizontal" | "vertical"; children: ReactElement[] | React.ReactNode; } export const ButtonGroup = ({ className, - orientation = 'horizontal', + orientation = "horizontal", children, }: ButtonGroupProps) => { - const isHorizontal = orientation === 'horizontal'; - const isVertical = orientation === 'vertical'; + const isHorizontal = orientation === "horizontal"; + const isVertical = orientation === "vertical"; // Normalize and filter only valid React elements - const childArray = Children.toArray(children).filter((child): child is ReactElement => - isValidElement(child) + const childArray = Children.toArray(children).filter( + (child): child is ReactElement => isValidElement(child), ); const totalButtons = childArray.length; return (
{childArray.map((child, index) => { @@ -41,18 +41,18 @@ export const ButtonGroup = ({ return cloneElement(child, { className: cn( { - 'rounded-l-none': isHorizontal && !isFirst, - 'rounded-r-none': isHorizontal && !isLast, - 'border-l-0': isHorizontal && !isFirst, + "rounded-l-none": isHorizontal && !isFirst, + "rounded-r-none": isHorizontal && !isLast, + "border-l-0": isHorizontal && !isFirst, - 'rounded-t-none': isVertical && !isFirst, - 'rounded-b-none': isVertical && !isLast, - 'border-t-0': isVertical && !isFirst, + "rounded-t-none": isVertical && !isFirst, + "rounded-b-none": isVertical && !isLast, + "border-t-0": isVertical && !isFirst, }, - child.props.className + child.props.className, ), }); })}
); -}; \ No newline at end of file +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 892d3d86..8b2e9e72 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -32,13 +32,13 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); export interface ButtonProps extends React.ComponentProps<"button">, VariantProps { - asChild?: boolean + asChild?: boolean; } function Button({ @@ -48,7 +48,7 @@ function Button({ asChild = false, ...props }: ButtonProps) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) + ); } -export { Button, buttonVariants, type ButtonProps } +export { Button, buttonVariants, type ButtonProps }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index d05bbc6c..113d66c7 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { data-slot="card" className={cn( "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", - className + className, )} {...props} /> - ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-header" className={cn( "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", - className + className, )} {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { className={cn("leading-none font-semibold", className)} {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { @@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-action" className={cn( "col-start-2 row-span-2 row-start-1 self-start justify-self-end", - className + className, )} {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { @@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { className={cn("px-6", className)} {...props} /> - ) + ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> - ) + ); } export { @@ -89,4 +89,4 @@ export { CardAction, CardDescription, CardContent, -} +}; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index defeb01f..29c5f2ed 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Checkbox({ className, @@ -13,7 +13,7 @@ function Checkbox({ data-slot="checkbox" className={cn( "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -24,7 +24,7 @@ function Checkbox({ - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 92bdb930..61ab08e9 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,25 +1,25 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( {children} -)) +)); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, @@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", - className + className, )} {...props} /> -)) +)); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -64,18 +64,18 @@ const DropdownMenuContent = React.forwardRef< sideOffset={sideOffset} className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", - className + className, )} {...props} /> -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< ref={ref} className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} checked={checked} {...props} @@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) +)); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, @@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef< ref={ref} className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} {...props} > @@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, @@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef< className={cn("bg-muted -mx-1 my-1 h-px", className)} {...props} /> -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, @@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({ className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { DropdownMenu, @@ -195,4 +195,4 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, -} \ No newline at end of file +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 7d7474cc..4ebbfe9c 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; import { Controller, FormProvider, @@ -9,23 +9,23 @@ import { type ControllerProps, type FieldPath, type FieldValues, -} from "react-hook-form" +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) + {} as FormFieldContextValue, +); const FormField = < TFieldValues extends FieldValues = FieldValues, @@ -37,21 +37,21 @@ const FormField = < - ) -} + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState } = useFormContext() - const formState = useFormState({ name: fieldContext.name }) - const fieldState = getFieldState(fieldContext.name, formState) + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error("useFormField should be used within ") + throw new Error("useFormField should be used within "); } - const { id } = itemContext + const { id } = itemContext; return { id, @@ -60,19 +60,19 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - } -} + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( - {} as FormItemContextValue -) + {} as FormItemContextValue, +); function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId() + const id = React.useId(); return ( @@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) { {...props} /> - ) + ); } function FormLabel({ className, ...props }: React.ComponentProps) { - const { error, formItemId } = useFormField() + const { error, formItemId } = useFormField(); return (