* Dev 1.8.0 (#399)

* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Add real client IP

* Fix OIDC credential persistence issue

The issue was that OIDC users were getting a new random Data Encryption Key (DEK)
on every login, which made previously encrypted credentials inaccessible.

Changes:
- Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key
- Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK
- Ensured OIDC users now have the same encryption persistence as password-based users

This fix ensures that credentials created by OIDC users remain accessible across
multiple login sessions.

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

* Replace jetbrains mono with caskaydia cove

* Fix alert issues

* Finalize font update

* Feature/german language support (#374)

* v1.7.2 (#364)

* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Add real client IP

* Fix OIDC credential persistence issue

The issue was that OIDC users were getting a new random Data Encryption Key (DEK)
on every login, which made previously encrypted credentials inaccessible.

Changes:
- Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key
- Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK
- Ensured OIDC users now have the same encryption persistence as password-based users

This fix ensures that credentials created by OIDC users remain accessible across
multiple login sessions.

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

---------

Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>

* Fix typos and improve wording in README.md

Corrected grammar and punctuation in README.

* Image 7.png

* Rename 3gi3b3os5psf1.png to Image 7.png

* Add video demonstration to README

Added a video demonstration to the README.

* Delete repo-images/Image 7.png

* Add files via upload

* Delete repo-images/Image 7.png

* Add files via upload

* Initial German translation

* German translation (#281)

* German translation (#281)

* Implementation of German language support  (#281)

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Add germanm support

* Fix SSH Key Password (keyPassword) Field Naming Mismatch Between Frontend and Backend (#375)

* Refactor key_password to keyPassword for consistency across SSH routes

* Standardizes keyPassword field handling and simplifies auth field logic

Standardizes the handling of the `keyPassword` field by converting
`key_password` to camelCase and ensuring consistent output while
preserving resolved credentials. Removes redundant snake_case
fields to avoid duplication.

Simplifies UI handling of authentication fields by allowing
non-relevant fields to persist, delegating filtering logic to the
backend for cleaner and more maintainable code.

Improves code clarity and aligns with consistent data handling
practices.

* Cleanup code + resolve conversion logic

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Feature disable password login (#378)

* Add admin toggle to disable password login

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/ui/main-axios.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/ui/Desktop/Admin/AdminSettings.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Add SSH TOTP authentication support (#350)

* Add SSH TOTP authentication support

- Implement keyboard-interactive authentication for SSH connections
- Add TOTP dialog component for Terminal and File Manager
- Handle TOTP prompts in WebSocket and HTTP connections
- Disable Server Stats for TOTP-enabled servers
- Add i18n support for TOTP-related messages

* Update src/backend/ssh/server-stats.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/ssh/file-manager.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Add terminal snippets feature with sidebar UI (#377)

* Add terminal snippets feature with sidebar UI

- Add snippets CRUD API endpoints and database schema
- Implement snippets sidebar accessible from TopNavbar
- Add copy to clipboard functionality
- Include tooltips and optimized styling
- Add English and Chinese translations

* Update src/backend/database/routes/snippets.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Feature engineering improvements (#376)

* chore: add engineering improvements

- Configure Prettier with unified code style rules
- Add husky + lint-staged for automated pre-commit checks
- Add commitlint to enforce conventional commit messages
- Add PR check workflow for CI automation
- Auto-format all files with Prettier
- Fix TypeScript any types in field-crypto.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: enhance development environment

- Add .editorconfig for unified editor settings
- Add .nvmrc to specify Node.js version (20)
- Add useful npm scripts: format, format:check, lint, lint:fix, type-check

* chore: add IDE and Git configuration

- Add VS Code workspace settings for consistent development experience
- Add VS Code extension recommendations (ESLint, Prettier, EditorConfig)
- Add .gitattributes to enforce LF line endings

* refactor: clean up unused variables and empty blocks

- database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.)
- database.ts: Fix empty catch blocks with descriptive comments
- database.ts: Add eslint-disable for required middleware parameter
- db/index.ts: Remove unused variables and fix empty catch blocks
- Temporarily remove ESLint from pre-commit to allow incremental fixes

Reduced total errors from 947 to 913 (34 fixes)

* refactor: clean up unused variables and empty blocks in routes

Routes updated:
- credentials.ts: Remove 12 unused variables/imports
- alerts.ts: Remove 1 unused variable
- users.ts: Remove 9 unused variables/imports

Changes:
- Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType)
- Fix empty catch blocks with descriptive comments
- Prefix reserved parameters with underscore
- Clean up unused error variables in catch blocks

Reduced errors from 913 to 886 (27 fixes)

* refactor: clean up unused variables in routes/ssh.ts

- Remove unused imports (NextFunction, jwt)
- Remove 6 unused variables (result, updateResult, name x3)
- All 8 no-unused-vars errors fixed

* refactor: clean up unused variables and empty blocks in file-manager.ts

- Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code)
- Fix 1 empty catch block
- Simplify multiple route handlers by removing unused destructured parameters

Reduced errors from 878 to 855 (23 fixes)

* refactor: clean up unused variables and empty blocks in utils

database-migration.ts:
- Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows)

lazy-field-encryption.ts:
- Fix 6 empty catch blocks with descriptive comments
- Keep error variables where they are used in logging

tunnel.ts:
- Fix multiple empty catch blocks
- Remove empty else blocks
- Partially fixed (10/21 issues resolved)

Reduced errors from 855 to 833 (22 fixes)

* fix: restore error variable in catch block for logging

Fix TypeScript error where error variable was removed from catch block
but still used in logging statements. The error variable is needed for
proper error logging and re-throwing.

* fix: clean up tunnel.ts empty blocks and unused variables

移除了 tunnel.ts 中的空块和未使用的变量:
- 移除 2 个空 else 块
- 修复 2 个空 if 块并添加注释
- 修复空错误处理器并添加注释
- 将未使用的 err 参数重命名为 _err

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks and unused variables in backend utils

修复了后端工具文件中的空块和未使用的变量:
- auth-manager.ts: 移除空 else 块
- system-crypto.ts: 修复空 catch 块并添加注释
- starter.ts: 修复空 catch 块并添加注释
- server-stats.ts: 将未使用的 reject 参数重命名为 _reject
- credentials.ts: 将 connectionTimeout 从 let 改为 const

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in frontend components

修复了前端组件中的空 catch 块:
- Tunnel.tsx: 修复空 catch 块并添加注释
- ServerConfig.tsx: 修复空 catch 块并添加注释
- TerminalKeyboard.tsx: 修复空 catch 块并添加注释
- system-crypto.ts: 修复遗漏的空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in backend utilities

修复了后端工具文件中的 10 个空 catch 块:
- system-crypto.ts: 修复 1 个空 catch 块
- server-stats.ts: 修复 4 个空 catch 块
- auto-ssl-setup.ts: 修复 1 个空 catch 块
- ssh-key-utils.ts: 修复 4 个空 catch 块

所有空块都添加了描述性注释说明为何忽略错误。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in UI hooks and components

修复了 5 个 UI 组件和 hooks 中的空 catch 块:
- useDragToSystemDesktop.ts: 修复 2 个空 catch 块
- HomepageAuth.tsx: 修复 1 个空 catch 块
- HostManagerEditor.tsx: 修复 2 个空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks in file manager and credential editor

修复了 5 个空块:
- FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块
- CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up all empty catch blocks in Terminal components

修复了 Terminal 组件中的所有 8 个空 catch 块:
- Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块
- Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块

所有空块都添加了描述性注释。这是空块修复的最后一批。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove useless try/catch wrappers

移除了 3 个无用的 try/catch 包装器:
- users.ts: 移除只重新抛出错误的外层 try/catch
- FileManager.tsx: 移除只重新抛出错误的内层 try/catch
- DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove unused imports and mark unused parameters

移除了未使用的导入和标记未使用的参数:
- auto-ssl-setup.ts: 移除未使用的 crypto 导入
- user-crypto.ts: 移除未使用的 users 导入
- user-data-import.ts: 移除未使用的 nanoid 导入
- simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unnecessary escape characters in regex patterns

移除了正则表达式中不必要的转义字符:
- users.ts: 修复 5 个 \/ 不必要的转义
- TabContext.tsx: 修复 1 个 \/ 不必要的转义

在字符串形式的正则表达式中,/ 不需要转义。

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>

* feat: enhance server stats widgets and fix TypeScript/ESLint errors (#394)

* feat: add draggable server stats dashboard with customizable widgets

* fix: widget deletion and layout persistence issues

* fix: improve widget deletion UX and add debug logs for persistence

* fix: resolve widget deletion and layout persistence issues

- Add drag handles to widget title bars for precise drag control
- Prevent delete button from triggering drag via event stopPropagation
- Include statsConfig field in all GET/PUT API responses
- Remove debug console logs from production code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: complete statsConfig field support across all API routes

- Add statsConfig to POST /db/host (create) route
- Add statsConfig to all GET routes for consistent API responses
- Remove incorrect statsConfig schema from HostManagerEditor
- statsConfig is now only managed by Server page layout editor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add statsConfig to metrics API response

- Add statsConfig field to SSHHostWithCredentials interface
- Include statsConfig in resolveHostCredentials baseHost object
- Ensures /metrics/:id API returns complete host configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: include statsConfig in SSH host create/update requests

The statsConfig field was being dropped by createSSHHost and updateSSHHost
functions in main-axios.ts, preventing layout customization from persisting.

Fixed by adding statsConfig to the submitData object in both functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: refactor server stats widgets into modular structure

Created dedicated widgets directory with individual components:
- CpuWidget, MemoryWidget, DiskWidget as separate components
- Widget registry for centralized widget configuration
- AddWidgetDialog for user-friendly widget selection
- Updated Server.tsx to use modular widget system

Benefits:
- Better code organization and maintainability
- Easier to add new widget types in the future
- Centralized widget metadata and configuration
- User can now add widgets via dialog interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: exit edit mode after saving layout

* feat: add customizable widget sizes with chart visualizations

Add three-tier size system (small/medium/large) for server stats widgets.
Integrate recharts library for visualizing trends in large widgets with
line charts (CPU), area charts (Memory), and radial bar charts (Disk).
Fix layout overflow issues with proper flexbox patterns.

* refactor: simplify server stats widget system

Replaced complex drag-and-drop grid layout with simple checkbox-based
configuration and static responsive grid display.

- Removed react-grid-layout dependency and 6 related packages
- Simplified StatsConfig from complex Widget objects to simple array
- Added Statistics tab in HostManagerEditor for checkbox selection
- Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout
- Simplified widget components by removing edit mode and size selection
- Deleted unused AddWidgetDialog and registry files
- Fixed statsConfig serialization in backend routes

Net result: -787 lines of code, cleaner architecture.

* feat: add system, uptime, network and processes widgets

Add four new server statistics widgets:
- SystemWidget: displays hostname, OS, and kernel information
- UptimeWidget: shows server total uptime with formatted display
- NetworkWidget: lists network interfaces with IP and status
- ProcessesWidget: displays top processes by CPU usage

Backend changes:
- Extended SSH metrics collection to gather network, uptime, process, and system data
- Added commands to parse /proc/uptime, ip addr, ps aux output

Frontend changes:
- Created 4 new widget components with consistent styling
- Updated widget type definitions and HostManagerEditor
- Unified all widget heights to 280px for consistent layout
- Added translations for all new widgets (EN/ZH)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: improve widget styling and UX consistency

Enhance all server stats widgets with improved styling and user experience:

Widget improvements:
- Fix hardcoded titles, now use i18n translations for all widgets
- Improve data formatting with consistent translation keys
- Enhance empty state displays with better visual hierarchy
- Add smooth hover transitions and visual feedback
- Standardize spacing and layout patterns across widgets

Specific optimizations:
- CPU: Use translated load average display
- Memory: Translate "Free" label
- Disk: Translate "Available" label
- System: Improve icon colors and spacing consistency
- Network: Better empty state, enhanced card styling
- Processes: Improved card borders and spacing

Visual polish:
- Unified icon sizing and opacity for empty states
- Consistent border radius (rounded-lg)
- Better hover states with subtle transitions
- Enhanced font weights for improved readability

* fix: replace explicit any types with proper TypeScript types

- Replace 'any' with 'unknown' in catch blocks and add type assertions
- Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle)
- Fix window/document object type extensions
- Update Electron API type definitions
- Improve type safety in database routes and utilities
- Add proper types to Terminal components (Desktop & Mobile)
- Fix navigation component types (TopNavbar, LeftSidebar, AppView)

Reduces TypeScript lint errors from 394 to 358 (-36 errors)
Fixes 45 @typescript-eslint/no-explicit-any violations

* fix: replace explicit any types with proper TypeScript types

- Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders)
- Add type definitions for WebSocket messages and SSH connection data
- Use generic types in DataCrypto methods instead of any return types
- Define proper interfaces for file manager data structures
- Replace catch block any types with unknown and proper type assertions
- Add HostConfig and TabData interfaces for Server component

Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files

* fix: resolve 6 TypeScript compilation errors

Fixed field name mismatches and generic type issues:
- database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields
- simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve unused variables in backend utils

Fixed @typescript-eslint/no-unused-vars errors in:
- starter.ts: removed unused error variables (2 fixes)
- auto-ssl-setup.ts: removed unused error variable (1 fix)
- ssh-key-utils.ts: removed unused error variables (3 fixes)
- user-crypto.ts: removed unused error variables (5 fixes)
- data-crypto.ts: removed unused plaintextFields and error variables (2 fixes)
- simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes)

Total: 15 unused variable errors fixed

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variable in terminal.ts

Fixed @typescript-eslint/no-unused-vars errors:
- Removed unused userPayload variable (line 123)
- Removed unused cols and rows from destructuring (line 348)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve unused variables in server-stats.ts

Fixed @typescript-eslint/no-unused-vars errors:
- Removed unused _reject parameter in Promise (line 64)
- Removed shadowed now variable in pollStatusesOnce (line 1130)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variables in tunnel.ts

Removed 5 unused variables:
- Removed unused data parameter from stdout event handler
- Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variables in main-axios.ts

Removed 8 unused variables:
- Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse)
- Removed unused apiPort variable
- Removed unused error variables in 3 catch blocks

* fix: remove unused variables in terminal.ts and starter.ts

Removed 2 unused variables:
- Removed unused JWTPayload type import from terminal.ts
- Removed unused _promise parameter from starter.ts

* fix: remove unused variables in sidebar.tsx

Removed 9 unused variables:
- Removed 5 unused Sheet component imports
- Removed unused SIDEBAR_WIDTH_MOBILE constant
- Removed 3 unused variables from useSidebar destructuring

* fix: remove 13 unused variables in frontend files

- version-check-modal.tsx: removed 4 unused imports and functions
- main.tsx: removed unused isMobile state
- AdminSettings.tsx: removed 8 unused imports and error variables

* fix: remove 28 unused variables across frontend components

Cleaned up unused imports, state variables, and function parameters:
- CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports)
- FileManager.tsx: removed 10 unused variables (icons, state, functions)
- Terminal.tsx (Desktop): removed 5 unused variables (state, handlers)
- Terminal.tsx (Mobile): removed 5 unused variables (imports, state)

Reduced lint errors from 271 to 236 (35 errors fixed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 10 unused variables in File Manager and config files

Cleaned up more unused imports, parameters, and variables:
- FileManagerGrid.tsx: removed 4 unused variables (params, function)
- FileManagerContextMenu.tsx: removed Share import
- FileManagerSidebar.tsx: removed onLoadDirectory parameter
- DraggableWindow.tsx: removed Square import
- FileWindow.tsx: removed updateWindow variable
- ServerConfig.tsx: removed 2 unused error parameters

Reduced lint errors from 236 to 222 (14 errors fixed total)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 7 unused variables in widgets and Homepage components

Cleaned up unused imports, parameters, and variables:
- DiskWidget.tsx: removed metricsHistory parameter
- FileManagerContextMenu.tsx: removed ExternalLink import
- Homepage.tsx: removed useTranslation import
- HomepageAlertManager.tsx: removed loading variable
- HomepageAuth.tsx: removed setCookie import (Desktop & Mobile)
- HompageUpdateLog.tsx: removed err parameter

Reduced lint errors from 222 to 216 (6 errors fixed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 8 unused variables in File Manager and Host Manager components

Cleaned up unused imports, state variables, and function parameters:
- DiffViewer.tsx: removed unused error parameter in catch block
- FileViewer.tsx: removed ReactPlayer import, unused originalContent state,
  node parameters from markdown code components, audio variable
- HostManager.tsx: removed onSelectView and updatedHost parameters
- TunnelViewer.tsx: removed TunnelConnection import

Reduced lint errors from 271 to 208 (63 errors fixed total)

* fix: remove 7 unused variables in UI hooks and components

Cleaned up unused parameters and functions:
- status/index.tsx: removed unused className parameter from StatusIndicator
- useDragToDesktop.ts: removed unused sshHost parameter and from dependency
  arrays (4 occurrences)
- useDragToSystemDesktop.ts: removed unused sshHost parameter and
  getLastSaveDirectory function (29 lines removed)

Continued reducing frontend lint errors

* fix: remove 2 unused variables in hooks and TabContext

- useDragToDesktop.ts: removed unused onSuccess in dragFolderToDesktop
- TabContext.tsx: removed unused useTranslation import and t variable

Continued reducing frontend lint errors

* fix: remove 2 unused variables in Homepage component

- Removed unused isAdmin state variable (changed to setter only)
- Removed unused jwt variable by inlining getCookie check

Continued reducing frontend lint errors

* fix: remove 3 unused variables in Mobile navigation components

- Host.tsx: removed unused Server icon import
- LeftSidebar.tsx: removed unused setHostsLoading setter and err parameter

Continued reducing frontend lint errors

* fix: remove 9 unused variables across multiple files

Fixed unused variables in:
- database-file-encryption.ts: removed currentFingerprint (backend)
- FileManagerContextMenu.tsx: removed ExternalLink import, hasDirectories
- frontend-logger.ts: removed 5 unused shortUrl variables

Continued reducing lint errors

* fix: remove 18 unused variables across 4 files

- HostManagerViewer.tsx: remove 9 unused error variables and parameters
- HostManagerEditor.tsx: remove WidgetType import, hosts/loading states, error variable
- CredentialViewer.tsx: remove 3 unused error variables
- Server.tsx: remove 2 unused error variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 9 unused variables across 4 files

- SnippetsSidebar.tsx: remove 3 unused err variables in catch blocks
- TunnelViewer.tsx: remove 2 unused parameters from callback
- DesktopApp.tsx: remove getCookie import and unused state variables
- HomepageAlertManager.tsx: remove 2 unused err variables in catch blocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 10 unused variables and imports across 4 navigation files

- Homepage.tsx: remove unused username state variable
- AppView.tsx: remove 3 unused Lucide icon imports
- TopNavbar.tsx: remove 4 unused Accordion component imports
- LeftSidebar.tsx: remove 2 unused variables (err, jwt)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 5 unused variables across 4 user/credentials files

- PasswordReset.tsx: remove unused result variable
- UserProfile.tsx: remove unused Key import and err variable
- version-check-modal.tsx: remove unused setVersionDismissed setter
- CredentialsManager.tsx: remove unused e parameter from handleDragLeave

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 2 unused variables in FileViewer and TerminalWindow

- FileViewer.tsx: remove unused node parameter from code component
- TerminalWindow.tsx: remove unused handleMinimize function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 10 unused variables in HomepageAuth.tsx

Removed unused variables:
- getCookie import
- dbError prop
- visibility state and toggleVisibility
- error state variable
- result variable in handleInitiatePasswordReset
- token URL parameter
- err parameters in catch blocks
- retryDatabaseConnection function
- Multiple setError(null) calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 9 unused variables across multiple files

Files fixed:
- DesktopApp.tsx: Removed _nextView parameter
- TerminalWindow.tsx: Removed minimizeWindow
- Mobile Host.tsx: Removed Server import
- Mobile LeftSidebar.tsx: Removed setHostsLoading, err in catch
- Desktop LeftSidebar.tsx: Removed getCookie, setCookie, onSelectView, getView, setHostsLoading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 10 unused variables in Mobile files

Files fixed:
- MobileApp.tsx: Removed getCookie, removeTab, isAdmin, id, err parameters
- Mobile/HomepageAuth.tsx: Removed getCookie, error state, result, token, err parameters

All @typescript-eslint/no-unused-vars errors in frontend now resolved!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused t variable in TabContext

Removed useTranslation import and unused t variable
in Mobile TabContext.tsx

All @typescript-eslint/no-unused-vars errors now resolved!
Total fixed: 154 unused variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve TypeScript and ESLint errors across the codebase

- Fixed @typescript-eslint/no-unused-vars errors (31 instances)
- Fixed @typescript-eslint/no-explicit-any errors in backend (~22 instances)
- Fixed @typescript-eslint/no-explicit-any errors in frontend (~60 instances)
- Fixed prefer-const errors (5 instances)
- Fixed no-empty-object-type and rules-of-hooks errors
- Added proper type assertions for database operations
- Improved type safety in authentication and encryption modules
- Enhanced type definitions for API routes and SSH operations

All TypeScript compilation errors resolved. Application builds and runs successfully.

* fix: disable react-refresh/only-export-components rule for component files

Disable the react-refresh/only-export-components ESLint rule in files
that export both components and related utilities (hooks, types,
constants). This is a pragmatic solution to maintain code organization
without splitting files unnecessarily.

* style: fix prettier formatting issues

Fix code style issues in translation file and TOTP dialog component
to pass CI prettier check.

* chore: fix rollup optional dependencies installation in CI

Add step to force reinstall rollup after npm ci to fix the known npm
bug with optional dependencies on Linux x64 platform.

* chore: fix lightningcss optional dependencies in CI

Add lightningcss to the force reinstall step to fix npm optional
dependencies bug for both rollup and lightningcss on Linux x64.

* chore: fix npm optional dependencies bug in CI

Remove package-lock.json and node_modules before install to properly
handle optional dependencies for rollup, lightningcss, and tailwindcss
native bindings on Linux x64 platform as recommended by npm.

* Update src/types/index.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Set terminal environment variables for SSH

Added environment variables for terminal configuration.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* feat: begin macOS support

* Delete .github/ISSUE_TEMPLATE/bug_report.yml

* Delete .github/ISSUE_TEMPLATE/feature_request.yml

* Add issue template configuration for support links

* Revise support instructions in README.md

Updated support section with new issue reporting instructions and clarified Discord support response times.

* Update repository links and badge URLs in README

* Update links to new orgnanization

* Migrate workflows to Blacksmith (#421)

Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com>

* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

* Feature/german language support (#374)

* v1.7.2 (#364)

* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Add real client IP

* Fix OIDC credential persistence issue

The issue was that OIDC users were getting a new random Data Encryption Key (DEK)
on every login, which made previously encrypted credentials inaccessible.

Changes:
- Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key
- Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK
- Ensured OIDC users now have the same encryption persistence as password-based users

This fix ensures that credentials created by OIDC users remain accessible across
multiple login sessions.

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

---------

Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>

* Fix typos and improve wording in README.md

Corrected grammar and punctuation in README.

* Image 7.png

* Rename 3gi3b3os5psf1.png to Image 7.png

* Add video demonstration to README

Added a video demonstration to the README.

* Delete repo-images/Image 7.png

* Add files via upload

* Delete repo-images/Image 7.png

* Add files via upload

* Initial German translation

* German translation (#281)

* German translation (#281)

* Implementation of German language support  (#281)

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/locales/de/translation.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Feature disable password login (#378)

* Add admin toggle to disable password login

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/ui/main-axios.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/ui/Desktop/Admin/AdminSettings.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/database/routes/users.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Add SSH TOTP authentication support (#350)

* Add SSH TOTP authentication support

- Implement keyboard-interactive authentication for SSH connections
- Add TOTP dialog component for Terminal and File Manager
- Handle TOTP prompts in WebSocket and HTTP connections
- Disable Server Stats for TOTP-enabled servers
- Add i18n support for TOTP-related messages

* Update src/backend/ssh/server-stats.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/backend/ssh/file-manager.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Add terminal snippets feature with sidebar UI (#377)

* Add terminal snippets feature with sidebar UI

- Add snippets CRUD API endpoints and database schema
- Implement snippets sidebar accessible from TopNavbar
- Add copy to clipboard functionality
- Include tooltips and optimized styling
- Add English and Chinese translations

* Update src/backend/database/routes/snippets.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Feature engineering improvements (#376)

* chore: add engineering improvements

- Configure Prettier with unified code style rules
- Add husky + lint-staged for automated pre-commit checks
- Add commitlint to enforce conventional commit messages
- Add PR check workflow for CI automation
- Auto-format all files with Prettier
- Fix TypeScript any types in field-crypto.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: enhance development environment

- Add .editorconfig for unified editor settings
- Add .nvmrc to specify Node.js version (20)
- Add useful npm scripts: format, format:check, lint, lint:fix, type-check

* chore: add IDE and Git configuration

- Add VS Code workspace settings for consistent development experience
- Add VS Code extension recommendations (ESLint, Prettier, EditorConfig)
- Add .gitattributes to enforce LF line endings

* refactor: clean up unused variables and empty blocks

- database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.)
- database.ts: Fix empty catch blocks with descriptive comments
- database.ts: Add eslint-disable for required middleware parameter
- db/index.ts: Remove unused variables and fix empty catch blocks
- Temporarily remove ESLint from pre-commit to allow incremental fixes

Reduced total errors from 947 to 913 (34 fixes)

* refactor: clean up unused variables and empty blocks in routes

Routes updated:
- credentials.ts: Remove 12 unused variables/imports
- alerts.ts: Remove 1 unused variable
- users.ts: Remove 9 unused variables/imports

Changes:
- Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType)
- Fix empty catch blocks with descriptive comments
- Prefix reserved parameters with underscore
- Clean up unused error variables in catch blocks

Reduced errors from 913 to 886 (27 fixes)

* refactor: clean up unused variables in routes/ssh.ts

- Remove unused imports (NextFunction, jwt)
- Remove 6 unused variables (result, updateResult, name x3)
- All 8 no-unused-vars errors fixed

* refactor: clean up unused variables and empty blocks in file-manager.ts

- Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code)
- Fix 1 empty catch block
- Simplify multiple route handlers by removing unused destructured parameters

Reduced errors from 878 to 855 (23 fixes)

* refactor: clean up unused variables and empty blocks in utils

database-migration.ts:
- Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows)

lazy-field-encryption.ts:
- Fix 6 empty catch blocks with descriptive comments
- Keep error variables where they are used in logging

tunnel.ts:
- Fix multiple empty catch blocks
- Remove empty else blocks
- Partially fixed (10/21 issues resolved)

Reduced errors from 855 to 833 (22 fixes)

* fix: restore error variable in catch block for logging

Fix TypeScript error where error variable was removed from catch block
but still used in logging statements. The error variable is needed for
proper error logging and re-throwing.

* fix: clean up tunnel.ts empty blocks and unused variables

移除了 tunnel.ts 中的空块和未使用的变量:
- 移除 2 个空 else 块
- 修复 2 个空 if 块并添加注释
- 修复空错误处理器并添加注释
- 将未使用的 err 参数重命名为 _err

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks and unused variables in backend utils

修复了后端工具文件中的空块和未使用的变量:
- auth-manager.ts: 移除空 else 块
- system-crypto.ts: 修复空 catch 块并添加注释
- starter.ts: 修复空 catch 块并添加注释
- server-stats.ts: 将未使用的 reject 参数重命名为 _reject
- credentials.ts: 将 connectionTimeout 从 let 改为 const

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in frontend components

修复了前端组件中的空 catch 块:
- Tunnel.tsx: 修复空 catch 块并添加注释
- ServerConfig.tsx: 修复空 catch 块并添加注释
- TerminalKeyboard.tsx: 修复空 catch 块并添加注释
- system-crypto.ts: 修复遗漏的空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in backend utilities

修复了后端工具文件中的 10 个空 catch 块:
- system-crypto.ts: 修复 1 个空 catch 块
- server-stats.ts: 修复 4 个空 catch 块
- auto-ssl-setup.ts: 修复 1 个空 catch 块
- ssh-key-utils.ts: 修复 4 个空 catch 块

所有空块都添加了描述性注释说明为何忽略错误。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in UI hooks and components

修复了 5 个 UI 组件和 hooks 中的空 catch 块:
- useDragToSystemDesktop.ts: 修复 2 个空 catch 块
- HomepageAuth.tsx: 修复 1 个空 catch 块
- HostManagerEditor.tsx: 修复 2 个空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks in file manager and credential editor

修复了 5 个空块:
- FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块
- CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up all empty catch blocks in Terminal components

修复了 Terminal 组件中的所有 8 个空 catch 块:
- Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块
- Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块

所有空块都添加了描述性注释。这是空块修复的最后一批。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove useless try/catch wrappers

移除了 3 个无用的 try/catch 包装器:
- users.ts: 移除只重新抛出错误的外层 try/catch
- FileManager.tsx: 移除只重新抛出错误的内层 try/catch
- DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove unused imports and mark unused parameters

移除了未使用的导入和标记未使用的参数:
- auto-ssl-setup.ts: 移除未使用的 crypto 导入
- user-crypto.ts: 移除未使用的 users 导入
- user-data-import.ts: 移除未使用的 nanoid 导入
- simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unnecessary escape characters in regex patterns

移除了正则表达式中不必要的转义字符:
- users.ts: 修复 5 个 \/ 不必要的转义
- TabContext.tsx: 修复 1 个 \/ 不必要的转义

在字符串形式的正则表达式中,/ 不需要转义。

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>

* feat: enhance server stats widgets and fix TypeScript/ESLint errors (#394)

* feat: add draggable server stats dashboard with customizable widgets

* fix: widget deletion and layout persistence issues

* fix: improve widget deletion UX and add debug logs for persistence

* fix: resolve widget deletion and layout persistence issues

- Add drag handles to widget title bars for precise drag control
- Prevent delete button from triggering drag via event stopPropagation
- Include statsConfig field in all GET/PUT API responses
- Remove debug console logs from production code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: complete statsConfig field support across all API routes

- Add statsConfig to POST /db/host (create) route
- Add statsConfig to all GET routes for consistent API responses
- Remove incorrect statsConfig schema from HostManagerEditor
- statsConfig is now only managed by Server page layout editor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add statsConfig to metrics API response

- Add statsConfig field to SSHHostWithCredentials interface
- Include statsConfig in resolveHostCredentials baseHost object
- Ensures /metrics/:id API returns complete host configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: include statsConfig in SSH host create/update requests

The statsConfig field was being dropped by createSSHHost and updateSSHHost
functions in main-axios.ts, preventing layout customization from persisting.

Fixed by adding statsConfig to the submitData object in both functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: refactor server stats widgets into modular structure

Created dedicated widgets directory with individual components:
- CpuWidget, MemoryWidget, DiskWidget as separate components
- Widget registry for centralized widget configuration
- AddWidgetDialog for user-friendly widget selection
- Updated Server.tsx to use modular widget system

Benefits:
- Better code organization and maintainability
- Easier to add new widget types in the future
- Centralized widget metadata and configuration
- User can now add widgets via dialog interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: exit edit mode after saving layout

* feat: add customizable widget sizes with chart visualizations

Add three-tier size system (small/medium/large) for server stats widgets.
Integrate recharts library for visualizing trends in large widgets with
line charts (CPU), area charts (Memory), and radial bar charts (Disk).
Fix layout overflow issues with proper flexbox patterns.

* refactor: simplify server stats widget system

Replaced complex drag-and-drop grid layout with simple checkbox-based
configuration and static responsive grid display.

- Removed react-grid-layout dependency and 6 related packages
- Simplified StatsConfig from complex Widget objects to simple array
- Added Statistics tab in HostManagerEditor for checkbox selection
- Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout
- Simplified widget components by removing edit mode and size selection
- Deleted unused AddWidgetDialog and registry files
- Fixed statsConfig serialization in backend routes

Net result: -787 lines of code, cleaner architecture.

* feat: add system, uptime, network and processes widgets

Add four new server statistics widgets:
- SystemWidget: displays hostname, OS, and kernel information
- UptimeWidget: shows server total uptime with formatted display
- NetworkWidget: lists network interfaces with IP and status
- ProcessesWidget: displays top processes by CPU usage

Backend changes:
- Extended SSH metrics collection to gather network, uptime, process, and system data
- Added commands to parse /proc/uptime, ip addr, ps aux output

Frontend changes:
- Created 4 new widget components with consistent styling
- Updated widget type definitions and HostManagerEditor
- Unified all widget heights to 280px for consistent layout
- Added translations for all new widgets (EN/ZH)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: improve widget styling and UX consistency

Enhance all server stats widgets with improved styling and user experience:

Widget improvements:
- Fix hardcoded titles, now use i18n translations for all widgets
- Improve data formatting with consistent translation keys
- Enhance empty state displays with better visual hierarchy
- Add smooth hover transitions and visual feedback
- Standardize spacing and layout patterns across widgets

Specific optimizations:
- CPU: Use translated load average display
- Memory: Translate "Free" label
- Disk: Translate "Available" label
- System: Improve icon colors and spacing consistency
- Network: Better empty state, enhanced card styling
- Processes: Improved card borders and spacing

Visual polish:
- Unified icon sizing and opacity for empty states
- Consistent border radius (rounded-lg)
- Better hover states with subtle transitions
- Enhanced font weights for improved readability

* fix: replace explicit any types with proper TypeScript types

- Replace 'any' with 'unknown' in catch blocks and add type assertions
- Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle)
- Fix window/document object type extensions
- Update Electron API type definitions
- Improve type safety in database routes and utilities
- Add proper types to Terminal components (Desktop & Mobile)
- Fix navigation component types (TopNavbar, LeftSidebar, AppView)

Reduces TypeScript lint errors from 394 to 358 (-36 errors)
Fixes 45 @typescript-eslint/no-explicit-any violations

* fix: replace explicit any types with proper TypeScript types

- Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders)
- Add type definitions for WebSocket messages and SSH connection data
- Use generic types in DataCrypto methods instead of any return types
- Define proper interfaces for file manager data structures
- Replace catch block any types with unknown and proper type assertions
- Add HostConfig and TabData interfaces for Server component

Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files

* fix: resolve 6 TypeScript compilation errors

Fixed field name mismatches and generic type issues:
- database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields
- simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve unused variables in backend utils

Fixed @typescript-eslint/no-unused-vars errors in:
- starter.ts: removed unused error variables (2 fixes)
- auto-ssl-setup.ts: removed unused error variable (1 fix)
- ssh-key-utils.ts: removed unused error variables (3 fixes)
- user-crypto.ts: removed unused error variables (5 fixes)
- data-crypto.ts: removed unused plaintextFields and error variables (2 fixes)
- simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes)

Total: 15 unused variable errors fixed

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variable in terminal.ts

Fixed @typescript-eslint/no-unused-vars errors:
- Removed unused userPayload variable (line 123)
- Removed unused cols and rows from destructuring (line 348)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve unused variables in server-stats.ts

Fixed @typescript-eslint/no-unused-vars errors:
- Removed unused _reject parameter in Promise (line 64)
- Removed shadowed now variable in pollStatusesOnce (line 1130)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variables in tunnel.ts

Removed 5 unused variables:
- Removed unused data parameter from stdout event handler
- Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unused variables in main-axios.ts

Removed 8 unused variables:
- Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse)
- Removed unused apiPort variable
- Removed unused error variables in 3 catch blocks

* fix: remove unused variables in terminal.ts and starter.ts

Removed 2 unused variables:
- Removed unused JWTPayload type import from terminal.ts
- Removed unused _promise parameter from starter.ts

* fix: remove unused variables in sidebar.tsx

Removed 9 unused variables:
- Removed 5 unused Sheet component imports
- Removed unused SIDEBAR_WIDTH_MOBILE constant
- Removed 3 unused variables from useSidebar destructuring

* fix: remove 13 unused variables in frontend files

- version-check-modal.tsx: removed 4 unused imports and functions
- main.tsx: removed unused isMobile state
- AdminSettings.tsx: removed 8 unused imports and error variables

* fix: remove 28 unused variables across frontend components

Cleaned up unused imports, state variables, and function parameters:
- CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports)
- FileManager.tsx: removed 10 unused variables (icons, state, functions)
- Terminal.tsx (Desktop): removed 5 unused variables (state, handlers)
- Terminal.tsx (Mobile): removed 5 unused variables (imports, state)

Reduced lint errors from 271 to 236 (35 errors fixed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 10 unused variables in File Manager and config files

Cleaned up more unused imports, parameters, and variables:
- FileManagerGrid.tsx: removed 4 unused variables (params, function)
- FileManagerContextMenu.tsx: removed Share import
- FileManagerSidebar.tsx: removed onLoadDirectory parameter
- DraggableWindow.tsx: removed Square import
- FileWindow.tsx: removed updateWindow variable
- ServerConfig.tsx: removed 2 unused error parameters

Reduced lint errors from 236 to 222 (14 errors fixed total)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 7 unused variables in widgets and Homepage components

Cleaned up unused imports, p…

* fix: Improve TOTP reliability, move components around, turn homepage update log into a sheet

* fix: Work more on TOTP, renamed homepage to dashboard and began improvements

* fix: test commit

* fix: Fix server stats login

* feat: Complete layout of Termix dashboard

* feat: Update font for reacent activity

* feat: Connect dashboard to backend and update tab system to be similar to a browser (neither are fully finished)

* feat: Improve dashboard API, improve tab system, various other fixes

* fix: Resize dashboard boxes and reduce server stats size to add scrolling

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix: Improve macOS support

* fix(auth): Fix admin user authentication for /users/db-health endpoint by adding cookie JWT support (#422)

Fixed authentication issue for admin users accessing the /users/db-health endpoint:

- Added JWT token extraction from cookies (req.cookies?.jwt)
- Added support for Bearer token from Authorization header
- Improved error handling for missing and invalid tokens
- Ensured consistent authentication flow for admin users

Changes made:
- Check for JWT token in req.cookies?.jwt
- Support Bearer token from Authorization header
- Return 401 error when token is missing
- Return 401 error when token validation fails

Fixes: https://github.com/Termix-SSH/Support/issues/12

* Update Docker login credentials and image names

* Update docker-image.yml

* Refactor Docker image workflow for registry options

Updated workflow to allow selection of Docker registry and simplified tag handling.

* Update Docker login conditions and tag handling

* Enhance Docker image workflow with better tagging

Updated Docker image workflow to improve tag handling and descriptions.

* Update Docker workflow for tag handling and cleanup

* Update docker-image.yml

* Update Docker workflow inputs and tag logic

Refactor Docker workflow to include version and production inputs, and streamline tag determination.

* Update Docker image workflow for multi-platform builds

* Refactor Docker image tags for clarity

Updated Docker image tags to use multi-line syntax for better readability and added latest tag conditionally.

* Fix typo in exposed ports in Dockerfile

* Update docker-image.yml

* Refactor Docker image workflow for registry handling

Removed registry input and adjusted Docker Hub login condition.

* Handle OIDC users during database import (#424)

* Update Docker image name for GitHub registry

* Fix image name casing in Docker workflow

* Remove untagged image cleanup step from workflow

Removed the step to delete untagged image versions from the workflow.

* Change Docker login to use GHCR credentials

Updated Docker login credentials for GitHub Container Registry.

* Remove cache moving step from Docker workflow

Removed the step to move the build cache in the Docker workflow.

* Refactor Docker image workflow for versioning and builds

* Update docker-image.yml

* Allow OIDC users to import database without password

* Skip import password prompt for OIDC users

* docs: clarify OIDC import unlocking flow

* docs: explain admin import password logic

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Nikola Novoselec <nikolanovoselec@users.noreply.github.com>

* fix: Fixed various issues with the dashboard, tab bar, and database issues

* feat: Added none password option and fixed some navbar issues (still present)

* fix: Fix tab reload/state loss whenever moving them to the rigbht

* feat: Make tabs auto expand and contract and scroll

* fix: Remove vertical scrolling in the tab bar and dashboard and reduce scrollbar height in tab bar

* feat: Add many terminal customizations

* feat: Add many terminal customizations

* fix: incorrect macOS logo, termix hangs on macOS, and macOS reporting incorrect version

* fix: fix macOS verison build error

* fix: fix macOS build error

* fix: replaced macOS icon

* feat: Added more output types for electron and streamlined the workflow

* fix: Rollup package issue

* fix: Rollup package issue for macOS

* feat: fix macOS/Linux build error

* fix: fix macOS build error

* fix: fix macOS build error and double folder issues

* fix: fix macOS build error

* fix: fix macOS build error

* fix: fix macOS build error

* fix: fix macOS build error

* fix: files uploading as folders instead of raw executable

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing

* fix: macOS build failing and update workflow options

* fix: macOS build failing

* fix: Upload to release not finding a release

* fix: ChaNge platform for upload to release

* fix: Standardize file naming

* fix: Build error with custom tar.gz naming

* fix: Allow .dmg signing

* fix: Fix .dmg signing

* fix: Fix notarize build error

* fix: Fix notarize build error

* fix: Add app specific password

* fix: add developer ID certificate

* fix: macOS app not closing

* fix: cache error

* Add Brazilian Portuguese translation (#425)

* Update Docker image name for GitHub registry

* Fix image name casing in Docker workflow

* Remove untagged image cleanup step from workflow

Removed the step to delete untagged image versions from the workflow.

* Change Docker login to use GHCR credentials

Updated Docker login credentials for GitHub Container Registry.

* Remove cache moving step from Docker workflow

Removed the step to move the build cache in the Docker workflow.

* Refactor Docker image workflow for versioning and builds

* Update docker-image.yml

* Add Brazilian Portuguese translation

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add chocolatey support

* feat: add initial flatpak/homebrew support

* fix: incorrect choco URL

* fix: rename choco package

* fix: updated package lock

* fix: move totp dialog

* feat: centralize SSH tools and allow multi terminal snippets

* fix: Squash commit of several fixes and features for many different elements

* fix: Fix some translations

* fix: pt-BR build error

* fix: npm build error

* fix: npm build error

* feat: rename gh actions

* fix: None auth and Host.tsx edit button issues

* fix: macOS dmg fail

* fix: linux not building x64

* fix: linux not uploading x64

* fix: Password reset issues, ODIC admin auth not filling, and electron x64 build issues

* feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web

* fix: Replace checkbox in docker build with dropdown

* fix: Issue with electron not displaying site

* fix: Issue with electron not displaying se

* fix: Issue with electron not displaying

* fix: Mobile reporting wrong user-agent

* fix: Nginx runtime error

* fix: JWT not persisting after reboot

* feat: add null to gitnore

* feat: remove sessions after reboot

* fix: File cleanup

* fix: Uncapitalize folder titles and finalize file cleanup

* fix: Build errors after cleanup

* fix: GITHUB_TOKEN issue in electron build

* fix: Random macOS build error

* fix: macOS GH token error

* fix: Incorrect desktop user agent and build issues

* fix: Notarize cleanup

* fix: None auth issues and macOS build failure and rename files for consistency

* fix: Run prettier

* feat: Update readme for iPadOS

* fix: Electron desktop not logging in

* fix: Electron desktop not logging in

* fix: Duplicated CORS headers

* fix: Electron login issues

* fix: Sqlite package fix

* fix: Desktop app login issues and rename version check and host manager folder

* fix: Electron HTTP fix + stripped background fix

* fix: Electron security issues and TOTP/None auth issues

* fix: Server config showing in web view

* fix: Update readme

* fix: Update readme

* [FEATURE] Adjustable Left Menu Width in Web Interface (#427)

#234
Added to LeftSidebar.tsx functionality
Update TopNavbar.tsx to use sidebar dynamic width

Co-authored-by: Robert Coroianu <robert.coroianu@easydo.co>

* fix: Sidebar resize issues and issues with TOTP interfering with password auth

* chore: Run prettier

* fix: Tunnels being same name

* fix: Electron build problems

* fix: Type error

* fix: Linux app image and server conifg issue

* fix: Run linter

* fix: Incorrect android user agent

* fix: No x64 appimage and server config displaying in electron webview

* fix: Electron API and terminal websocket issues

* fix: Android user agent edgecase and electron using web view incorrectly

* feat: Added mobile and electron UI redirecting system

* fix: Fix electron login and mobile redirect

* feat: add Russian translation and readme (#428)

* Update Docker image name for GitHub registry

* Fix image name casing in Docker workflow

* Remove untagged image cleanup step from workflow

Removed the step to delete untagged image versions from the workflow.

* Change Docker login to use GHCR credentials

Updated Docker login credentials for GitHub Container Registry.

* Remove cache moving step from Docker workflow

Removed the step to move the build cache in the Docker workflow.

* Refactor Docker image workflow for versioning and builds

* Update docker-image.yml

* Update print statement from 'Hello' to 'Goodbye'

* Update docker build

* Rename docker-image.yml to docker.yml

* Rename electron-build.yml to electron.yml

* feat: add Russian translation and readme

* feat: Added mobile and electron UI redirecting system

* fix: Fix electron login and mobile redirect

* Update Docker image name for GitHub registry

* Fix image name casing in Docker workflow

* Remove untagged image cleanup step from workflow

Removed the step to delete untagged image versions from the workflow.

* Change Docker login to use GHCR credentials

Updated Docker login credentials for GitHub Container Registry.

* Remove cache moving step from Docker workflow

Removed the step to move the build cache in the Docker workflow.

* Refactor Docker image workflow for versioning and builds

* Update docker-image.yml

* Update print statement from 'Hello' to 'Goodbye'

* Update docker build

* Rename docker-image.yml to docker.yml

* Rename electron-build.yml to electron.yml

* feat: add Russian translation and readme

* fix: Add russian

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: root <root@codeserver.192.168.0.5>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: remove russian readme

* fix: Revert workflows back to normal

* fix: Session invoking all sessions and mobile success redirect not displaying

* fix: Logging out on one device logs out all on same user

* fix: Improve session clearing (possible RC)

* fix: Linux portable naming incorrect

* fix: Linux desktop not opening

* fix: Linux build failure

* fix: Linux build failure

* fix: Linux build failure

* fix: Linux build failure

* fix: Linux sandbox issue

* fix: Linux sandbox issue

* fix: Linux sandbox issue

* fix: Finalize electron

* fix: Database check failure (release cantidate)

* fix: Run cleanup and final fix for electron

---------

Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>
Co-authored-by: P3RF3CTION <herzmaximilian@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: ZacharyZcR <2903735704@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com>
Co-authored-by: suraimu-team <team@suraimu.com>
Co-authored-by: Nikola Novoselec <12149536+nikolanovoselec@users.noreply.github.com>
Co-authored-by: Nikola Novoselec <nikolanovoselec@users.noreply.github.com>
Co-authored-by: xhemp <13650956+xhemp@users.noreply.github.com>
Co-authored-by: Robert Coroianu <robert.coroianu@gmail.com>
Co-authored-by: Robert Coroianu <robert.coroianu@easydo.co>
Co-authored-by: shizaterrorblade <shizaterrorblayde@gmail.com>
Co-authored-by: root <root@codeserver.192.168.0.5>
This commit was merged in pull request #429.
This commit is contained in:
Luke Gustafson
2025-11-05 10:36:16 -06:00
committed by GitHub
parent dc29646a39
commit 8ec22b2177
196 changed files with 28495 additions and 12878 deletions

21
.commitlintrc.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"chore",
"revert"
]
],
"subject-case": [0]
}
}

View File

@@ -1,29 +1,24 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
build
.next
.nuxt
# Development files
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
@@ -32,98 +27,67 @@ build
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
README-CN.md
CONTRIBUTING.md
LICENSE
# Docker files (avoid copying docker files into docker)
# docker/ - commented out to allow entrypoint.sh to be copied
# Repository images
repo-images/
# Uploads directory
uploads/
# Electron files (not needed for Docker)
electron/
electron-builder.json
# Development and build artifacts
*.log
*.tmp
*.temp
# Font files (we'll optimize these in Dockerfile)
# public/fonts/*.ttf
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.tern-port

14
.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

31
.gitattributes vendored Normal file
View File

@@ -0,0 +1,31 @@
* text=auto eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.sh text eol=lf
*.bash text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

View File

@@ -6,11 +6,14 @@ on:
version:
description: "Version to build (e.g., 1.8.0)"
required: true
production:
description: "Is this a production build?"
build_type:
description: "Build type"
required: true
default: false
type: boolean
default: "Development"
type: choice
options:
- Development
- Production
jobs:
build:
@@ -33,29 +36,25 @@ jobs:
id: tags
run: |
VERSION=${{ github.event.inputs.version }}
PROD=${{ github.event.inputs.production }}
BUILD_TYPE=${{ github.event.inputs.build_type }}
TAGS=()
ALL_TAGS=()
if [ "$PROD" = "true" ]; then
# Production build → push release + latest to both GHCR and Docker Hub
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "latest")
for tag in "${TAGS[@]}"; do
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
done
else
# Dev build → push only dev-x.x.x to GHCR
TAGS+=("dev-$VERSION")
for tag in "${TAGS[@]}"; do
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
done
fi
echo "ALL_TAGS=${ALL_TAGS[*]}" >> $GITHUB_ENV
echo "All tags to build:"
printf '%s\n' "${ALL_TAGS[@]}"
echo "ALL_TAGS=$(printf '%s\n' "${ALL_TAGS[@]}")" >> $GITHUB_ENV
- name: Login to GHCR
uses: docker/login-action@v3
@@ -65,7 +64,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Login to Docker Hub (prod only)
if: ${{ github.event.inputs.production == 'true' }}
if: ${{ github.event.inputs.build_type == 'Production' }}
uses: docker/login-action@v3
with:
username: bugattiguy527

File diff suppressed because it is too large Load Diff

35
.github/workflows/pr-check.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: PR Check
on:
pull_request:
branches: [main, dev-*]
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: |
rm -rf node_modules package-lock.json
npm install
- name: Run ESLint
run: npx eslint .
- name: Run Prettier check
run: npx prettier --check .
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build

5
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# Logs
logs
*.log
npm-debug.log*
@@ -12,7 +11,6 @@ dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
@@ -27,3 +25,6 @@ dist-ssr
/.claude/
/ssl/
.env
/.mcp.json
/nul
/.vscode/

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

2
.nvmrc
View File

@@ -1 +1 @@
22
20

View File

@@ -1,3 +1,18 @@
# Ignore artifacts:
build
coverage
dist
dist-ssr
release
node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
db
.env
*.min.js
*.min.css
openapi.json

View File

@@ -1 +1,9 @@
{}
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@@ -103,4 +103,4 @@ This will start the backend and the frontend Vite server. You can access Termix
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel, however, response times may be longer.
channel, however, response times may be longer.

61
DOWNLOADS.md Normal file
View File

@@ -0,0 +1,61 @@
# Termix Download Links
## Windows
| Architecture | Type | Download Link |
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- |
| x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
| x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
| ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
| ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
| ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
## Linux
| Architecture | Type | Download Link |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
| x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
| arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
| arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
| arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
| armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
| armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
| armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
## macOS
| Architecture | Type | Download Link |
| ------------ | ------------- | -------------------------------------------------------------------------------------------------------- |
| Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
| Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
| x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
| arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
---
## All Platforms - Complete Download List
| Platform | Architecture | Type | Download Link |
| -------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------- |
| Windows | x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
| Windows | x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
| Windows | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
| Windows | ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
| Windows | ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
| Windows | ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
| Linux | x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
| Linux | x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
| Linux | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
| Linux | arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
| Linux | arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
| Linux | arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
| Linux | armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
| Linux | armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
| Linux | armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
| macOS | Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
| macOS | Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
| macOS | x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
| macOS | arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |

View File

@@ -1,7 +1,7 @@
# 仓库统计
<p align="center">
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
</p>
@@ -44,22 +44,24 @@
</p>
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案通过一个直观的界面管理你的服务器和基础设施。Termix
提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。
提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
# 功能
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重和健康监控
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图、音频和视频。无缝上传、下载、重命名、删除和移动文件
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹,轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
- **服务器统计** - 查看任意 SSH 服务器 CPU、内存和盘使用情况
- **用户认证** - 安全的用户管理支持管理员控制、OIDC 和双因素认证TOTP
- **数据库加密** - SQLite 数据库文件在静态时加密,支持自动加密/解密
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据,支持增量同步
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图、音频和视频。无缝上传、下载、重命名、删除和移动文件
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和盘使用情况以及网络、正常运行时间和系统信息
- **仪表板** - 在仪表板上一目了然地查看服务器信息
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
- **现代界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁桌面/移动友好界面
- **语言支持** - 内置英语、中文和德语支持
- **平台支持** - 提供 Web 应用、桌面应用程序WindowsLinux)以及 iOS 和 Android 专用移动应用。计划支持 macOS 和 iPadOS
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁桌面/移动设备友好界面
- **语言** - 内置支持英语、中文、德语和葡萄牙语
- **平台支持** - 可作为 Web 应用程序、桌面应用程序WindowsLinux 和 macOS以及适用于 iOS 和 Android 专用移动/平板电脑应用程序
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
# 计划功能
@@ -69,14 +71,28 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
支持的设备:
- 网站(任何现代浏览器,如 Google、Safari 和 Firefox
- Windows应用程序
- Linux应用程序
- iOS应用程序
- Android应用程序
- iPadOS 和 macOS 正在开发中
- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox
- Windowsx64/ia32
- 便携版
- MSI 安装程序
- Chocolatey 软件包管理器
- Linuxx64/ia32
- 便携版
- AppImage
- Deb
- Flatpak
- macOSx64/ia32 on v12.0+
- Apple App Store
- DMG
- Homebrew
- iOS/iPadOSv15.1+
- Apple App Store
- ISO
- Androidv7.0+
- Google Play 商店
- APK
访问 Termix [文档](https://docs.termix.site/install) 获取所有平台安装信息。或者可以参考以下示例 docker-compose 文件:
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件:
```yaml
services:
@@ -128,6 +144,7 @@ volumes:
你的浏览器不支持 video 标签。
</video>
</p>
视频和图像可能已过时。
# 许可证

View File

@@ -43,24 +43,27 @@ If you would like, you can support the project here!\
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
access, SSH tunneling capabilities, and remote file management, with many more tools to come.
access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect
free and self-hosted alternative to Termius available for all platforms.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly.
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders and easily save reusable login info while being able to automate the deploying of SSH keys
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
- **Dashboard** - View server information at a glance on your dashboard
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions.
- **Database Encryption** - Backend stored as encrypted SQLite database files
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English, Chinese, and German
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android. macOS and iPadOS support is planned.
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
# Planned Features
@@ -70,12 +73,26 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe
Supported Devices:
- Website (any modern browser like Google, Safari, and Firefox)
- Windows (app)
- Linux (app)
- iOS (app)
- Android (app)
- iPadOS and macOS are in progress
- Website (any modern browser on any platform like Chrome, Safari, and Firefox)
- Windows (x64/ia32)
- Portable
- MSI Installer
- Chocolatey Package Manager
- Linux (x64/ia32)
- Portable
- AppImage
- Deb
- Flatpak
- macOS (x64/ia32 on v12.0+)
- Apple App Store
- DMG
- Homebrew
- iOS/iPadOS (v15.1+)
- Apple App Store
- ISO
- Android (v7.0+)
- Google Play Store
- APK
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
a sample Docker Compose file here:
@@ -130,6 +147,7 @@ channel, however, response times may be longer.
Your browser does not support the video tag.
</video>
</p>
Videos and images may be out of date.
# License

Binary file not shown.

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

31
build/notarize.cjs Normal file
View File

@@ -0,0 +1,31 @@
const { notarize } = require('@electron/notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
const appleId = process.env.APPLE_ID;
const appleIdPassword = process.env.APPLE_ID_PASSWORD;
const teamId = process.env.APPLE_TEAM_ID;
if (!appleId || !appleIdPassword || !teamId) {
return;
}
const appName = context.packager.appInfo.productFilename;
try {
await notarize({
appBundleId: 'com.karmaa.termix',
appPath: `${appOutDir}/${appName}.app`,
appleId: appleId,
appleIdPassword: appleIdPassword,
teamId: teamId,
});
} catch (error) {
console.error('Notarization failed:', error);
}
};

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>termix-ssh</id>
<version>VERSION_PLACEHOLDER</version>
<packageSourceUrl>https://github.com/Termix-SSH/Termix</packageSourceUrl>
<owners>bugattiguy527</owners>
<title>Termix SSH</title>
<authors>bugattiguy527</authors>
<projectUrl>https://github.com/Termix-SSH/Termix</projectUrl>
<iconUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png</iconUrl>
<licenseUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/refs/heads/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/Termix-SSH/Termix</projectSourceUrl>
<docsUrl>https://docs.termix.site/install</docsUrl>
<bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
<tags>docker ssh self-hosted file-management ssh-tunnel termix server-management terminal</tags>
<summary>Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities.</summary>
<description>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
Termix offers:
- SSH terminal access
- SSH tunneling capabilities
- Remote file management
- Server monitoring and management
This package installs the desktop application version of Termix.
</description>
<releaseNotes>https://github.com/Termix-SSH/Termix/releases</releaseNotes>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>

View File

@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$packageName = 'termix-ssh'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$url64 = 'DOWNLOAD_URL_PLACEHOLDER'
$checksum64 = 'CHECKSUM_PLACEHOLDER'
$checksumType64 = 'sha256'
$packageArgs = @{
packageName = $packageName
fileType = 'msi'
url64bit = $url64
softwareName = 'Termix*'
checksum64 = $checksum64
checksumType64 = $checksumType64
silentArgs = "/qn /norestart /l*v `"$($env:TEMP)\$($packageName).$($env:chocolateyPackageVersion).MsiInstall.log`""
validExitCodes = @(0, 3010, 1641)
}
Install-ChocolateyPackage @packageArgs

View File

@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
$packageName = 'termix-ssh'
$softwareName = 'Termix*'
$installerType = 'msi'
$silentArgs = '/qn /norestart'
$validExitCodes = @(0, 3010, 1605, 1614, 1641)
[array]$key = Get-UninstallRegistryKey -SoftwareName $softwareName
if ($key.Count -eq 1) {
$key | % {
$file = "$($_.UninstallString)"
if ($installerType -eq 'msi') {
$silentArgs = "$($_.PSChildName) $silentArgs"
$file = ''
}
Uninstall-ChocolateyPackage -PackageName $packageName `
-FileType $installerType `
-SilentArgs "$silentArgs" `
-ValidExitCodes $validExitCodes `
-File "$file"
}
} elseif ($key.Count -eq 0) {
Write-Warning "$packageName has already been uninstalled by other means."
} elseif ($key.Count -gt 1) {
Write-Warning "$($key.Count) matches found!"
Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
$key | % {Write-Warning "- $($_.DisplayName)"}
}

View File

@@ -2,16 +2,12 @@
FROM node:22-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN rm -rf node_modules package-lock.json && \
npm install --force && \
npm install --ignore-scripts --force && \
npm cache clean --force
# Stage 2: Build frontend
@@ -31,10 +27,6 @@ WORKDIR /app
COPY . .
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm rebuild better-sqlite3 --force
RUN npm run build:backend
@@ -47,10 +39,6 @@ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt
COPY package*.json ./
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm ci --only=production --ignore-scripts --force && \
npm rebuild better-sqlite3 bcryptjs --force && \
npm cache clean --force
@@ -82,8 +70,8 @@ COPY --chown=node:node package.json ./
VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
CMD ["/entrypoint.sh"]

View File

@@ -34,7 +34,6 @@ http {
ssl_certificate_key ${SSL_KEY_PATH};
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
@@ -49,6 +48,15 @@ http {
log_not_found off;
}
location ~ ^/users/sessions(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -92,27 +100,36 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location ~ ^/database(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/database(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -120,18 +137,18 @@ http {
location ~ ^/db(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -216,18 +233,18 @@ http {
location /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30004;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -259,9 +276,27 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/uptime(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/activity(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
}

View File

@@ -23,13 +23,20 @@ http {
listen ${PORT};
server_name localhost;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location ~* \.map$ {
@@ -38,6 +45,15 @@ http {
log_not_found off;
}
location ~ ^/users/sessions(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -81,27 +97,36 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location ~ ^/database(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/database(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -109,18 +134,18 @@ http {
location ~ ^/db(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -205,18 +230,18 @@ http {
location /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30004;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
@@ -248,9 +273,27 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/uptime(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/activity(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
}

View File

@@ -1,6 +1,7 @@
{
"appId": "com.termix.app",
"appId": "com.karmaa.termix",
"productName": "Termix",
"publish": null,
"directories": {
"output": "release"
},
@@ -21,35 +22,53 @@
},
"buildDependenciesFromSource": false,
"nodeGypRebuild": false,
"npmRebuild": false,
"npmRebuild": true,
"win": {
"target": "nsis",
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
},
{
"target": "msi",
"arch": ["x64", "ia32"]
}
],
"icon": "public/icon.ico",
"executableName": "Termix"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-Setup-${version}.${ext}",
"artifactName": "termix_windows_${arch}_nsis.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Termix",
"uninstallDisplayName": "Termix"
},
"msi": {
"artifactName": "termix_windows_${arch}_msi.${ext}"
},
"linux": {
"artifactName": "termix_linux_${arch}_portable.${ext}",
"target": [
{
"target": "AppImage",
"arch": ["x64"]
"arch": ["x64", "arm64", "armv7l"]
},
{
"target": "deb",
"arch": ["x64", "arm64", "armv7l"]
},
{
"target": "tar.gz",
"arch": ["x64"]
"arch": ["x64", "arm64", "armv7l"]
}
],
"icon": "public/icon.png",
"category": "Development",
"executableName": "termix",
"maintainer": "Termix <mail@termix.site>",
"desktop": {
"entry": {
"Name": "Termix",
@@ -58,5 +77,52 @@
"StartupWMClass": "termix"
}
}
},
"appImage": {
"artifactName": "termix_linux_${arch}_appimage.${ext}"
},
"deb": {
"artifactName": "termix_linux_${arch}_deb.${ext}"
},
"mac": {
"target": [
{
"target": "mas",
"arch": "universal"
},
{
"target": "dmg",
"arch": ["universal", "x64", "arm64"]
}
],
"icon": "public/icon.icns",
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"type": "distribution",
"minimumSystemVersion": "10.15"
},
"dmg": {
"artifactName": "termix_macos_${arch}_dmg.${ext}",
"sign": true
},
"afterSign": "build/notarize.cjs",
"mas": {
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
"entitlements": "build/entitlements.mas.plist",
"entitlementsInherit": "build/entitlements.mas.inherit.plist",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"asarUnpack": ["**/*.node"],
"type": "distribution",
"category": "public.app-category.developer-tools",
"artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": {
"ITSAppUsesNonExemptEncryption": false,
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
}
}

View File

@@ -1,22 +1,34 @@
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
const {
app,
BrowserWindow,
shell,
ipcMain,
dialog,
Menu,
} = require("electron");
const path = require("path");
const fs = require("fs");
const os = require("os");
if (process.platform === "linux") {
app.commandLine.appendSwitch("--no-sandbox");
app.commandLine.appendSwitch("--disable-setuid-sandbox");
app.commandLine.appendSwitch("--disable-dev-shm-usage");
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("--disable-gpu");
app.commandLine.appendSwitch("--disable-gpu-compositing");
}
app.commandLine.appendSwitch("--ignore-certificate-errors");
app.commandLine.appendSwitch("--ignore-ssl-errors");
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
app.commandLine.appendSwitch("--enable-features=NetworkService");
if (process.platform === "linux") {
app.commandLine.appendSwitch("--no-sandbox");
app.commandLine.appendSwitch("--disable-setuid-sandbox");
app.commandLine.appendSwitch("--disable-dev-shm-usage");
}
let mainWindow = null;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
@@ -34,40 +46,131 @@ if (!gotTheLock) {
}
function createWindow() {
const appVersion = app.getVersion();
const electronVersion = process.versions.electron;
const platform =
process.platform === "win32"
? "Windows"
: process.platform === "darwin"
? "macOS"
: "Linux";
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"),
icon: path.join(appRoot, "public", "icon.png"),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
webSecurity: false,
preload: path.join(__dirname, "preload.js"),
partition: "persist:termix",
allowRunningInsecureContent: true,
webviewTag: true,
offscreen: false,
},
show: false,
show: true,
});
if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false);
}
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
mainWindow.webContents.setUserAgent(customUserAgent);
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders["X-Electron-App"] = "true";
details.requestHeaders["User-Agent"] = customUserAgent;
callback({ requestHeaders: details.requestHeaders });
},
);
if (isDev) {
mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, "..", "dist", "index.html");
mainWindow.loadFile(indexPath);
const indexPath = path.join(appRoot, "dist", "index.html");
mainWindow.loadFile(indexPath).catch((err) => {
console.error("Failed to load file:", err);
});
}
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
const headers = details.responseHeaders;
if (headers) {
delete headers["x-frame-options"];
delete headers["X-Frame-Options"];
if (headers["content-security-policy"]) {
headers["content-security-policy"] = headers[
"content-security-policy"
]
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
.filter((value) => value.trim().length > 0);
if (headers["content-security-policy"].length === 0) {
delete headers["content-security-policy"];
}
}
if (headers["Content-Security-Policy"]) {
headers["Content-Security-Policy"] = headers[
"Content-Security-Policy"
]
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
.filter((value) => value.trim().length > 0);
if (headers["Content-Security-Policy"].length === 0) {
delete headers["Content-Security-Policy"];
}
}
if (headers["set-cookie"]) {
headers["set-cookie"] = headers["set-cookie"].map((cookie) => {
let modified = cookie.replace(
/;\s*SameSite=Strict/gi,
"; SameSite=None",
);
modified = modified.replace(
/;\s*SameSite=Lax/gi,
"; SameSite=None",
);
if (!modified.includes("SameSite=")) {
modified += "; SameSite=None";
}
if (
!modified.includes("Secure") &&
details.url.startsWith("https")
) {
modified += "; Secure";
}
return modified;
});
}
}
callback({ responseHeaders: headers });
},
);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
setTimeout(() => {
if (mainWindow && !mainWindow.isVisible()) {
mainWindow.show();
}
}, 3000);
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription, validatedURL) => {
@@ -84,13 +187,6 @@ function createWindow() {
console.log("Frontend loaded successfully");
});
mainWindow.on("close", (event) => {
if (process.platform === "darwin") {
event.preventDefault();
mainWindow.hide();
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
@@ -462,21 +558,78 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
}
});
function createMenu() {
if (process.platform === "darwin") {
const template = [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
{ type: "separator" },
{ role: "front" },
{ type: "separator" },
{ role: "window" },
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
}
app.whenReady().then(() => {
createMenu();
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});

View File

@@ -0,0 +1,11 @@
[Desktop Entry]
Name=Termix
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Exec=termix %U
Icon=com.karmaa.termix
Terminal=false
Type=Application
Categories=Development;Network;System;
Keywords=ssh;terminal;server;management;tunnel;
StartupWMClass=termix
StartupNotify=true

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.karmaa.termix</id>
<name>Termix</name>
<summary>Web-based server management platform with SSH terminal, tunneling, and file editing</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<developer_name>bugattiguy527</developer_name>
<description>
<p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform.
It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
</p>
<p>Features:</p>
<ul>
<li>SSH terminal access with full terminal emulation</li>
<li>SSH tunneling capabilities for secure port forwarding</li>
<li>Remote file management with editor support</li>
<li>Server monitoring and management tools</li>
<li>Self-hosted solution - keep your data private</li>
<li>Modern, intuitive web interface</li>
</ul>
</description>
<launchable type="desktop-id">com.karmaa.termix.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/screenshots/terminal.png</image>
<caption>SSH Terminal Interface</caption>
</screenshot>
</screenshots>
<url type="homepage">https://github.com/Termix-SSH/Termix</url>
<url type="bugtracker">https://github.com/Termix-SSH/Support/issues</url>
<url type="help">https://docs.termix.site</url>
<url type="vcs-browser">https://github.com/Termix-SSH/Termix</url>
<content_rating type="oars-1.1">
<content_attribute id="social-info">moderate</content_attribute>
</content_rating>
<releases>
<release version="VERSION_PLACEHOLDER" date="DATE_PLACEHOLDER">
<description>
<p>Latest release of Termix</p>
</description>
<url>https://github.com/Termix-SSH/Termix/releases</url>
</release>
</releases>
<categories>
<category>Development</category>
<category>Network</category>
<category>System</category>
</categories>
<keywords>
<keyword>ssh</keyword>
<keyword>terminal</keyword>
<keyword>server</keyword>
<keyword>management</keyword>
<keyword>tunnel</keyword>
<keyword>file-manager</keyword>
</keywords>
<provides>
<binary>termix</binary>
</provides>
<requires>
<internet>always</internet>
</requires>
</component>

View File

@@ -0,0 +1,69 @@
app-id: com.karmaa.termix
runtime: org.freedesktop.Platform
runtime-version: "23.08"
sdk: org.freedesktop.Sdk
base: org.electronjs.Electron2.BaseApp
base-version: "23.08"
command: termix
separate-locales: false
finish-args:
- --socket=x11
- --socket=wayland
- --socket=pulseaudio
- --share=network
- --share=ipc
- --device=dri
- --filesystem=home
- --socket=ssh-auth
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.secrets
modules:
- name: termix
buildsystem: simple
build-commands:
- chmod +x termix.AppImage
- ./termix.AppImage --appimage-extract
- install -Dm755 squashfs-root/termix /app/bin/termix
- cp -r squashfs-root/resources /app/bin/
- cp -r squashfs-root/locales /app/bin/ || true
- install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop
- install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml
- install -Dm644 com.karmaa.termix.svg /app/share/icons/hicolor/scalable/apps/com.karmaa.termix.svg
- install -Dm644 icon-256.png /app/share/icons/hicolor/256x256/apps/com.karmaa.termix.png || true
- install -Dm644 icon-128.png /app/share/icons/hicolor/128x128/apps/com.karmaa.termix.png || true
sources:
- type: file
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_VERSION_PLACEHOLDER_appimage.AppImage
sha256: CHECKSUM_X64_PLACEHOLDER
dest-filename: termix.AppImage
only-arches:
- x86_64
- type: file
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_VERSION_PLACEHOLDER_appimage.AppImage
sha256: CHECKSUM_ARM64_PLACEHOLDER
dest-filename: termix.AppImage
only-arches:
- aarch64
- type: file
path: com.karmaa.termix.desktop
- type: file
path: com.karmaa.termix.metainfo.xml
- type: file
path: com.karmaa.termix.svg
- type: file
path: icon-256.png
- type: file
path: icon-128.png

5
flatpak/flathub.json Normal file
View File

@@ -0,0 +1,5 @@
{
"only-arches": ["x86_64", "aarch64"],
"skip-icons-check": false,
"skip-appstream-check": false
}

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -e
VERSION="$1"
CHECKSUM="$2"
RELEASE_DATE="$3"
if [ -z "$VERSION" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_DATE" ]; then
echo "Usage: $0 <version> <checksum> <release-date>"
echo "Example: $0 1.8.0 abc123... 2025-10-26"
exit 1
fi
echo "Preparing Flatpak submission for version $VERSION"
cp public/icon.svg flatpak/com.karmaa.termix.svg
echo "✓ Copied SVG icon"
if command -v convert &> /dev/null; then
convert public/icon.png -resize 256x256 flatpak/icon-256.png
convert public/icon.png -resize 128x128 flatpak/icon-128.png
echo "✓ Generated PNG icons"
else
cp public/icon.png flatpak/icon-256.png
cp public/icon.png flatpak/icon-128.png
echo "⚠ ImageMagick not found, using original icon"
fi
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
echo "✓ Updated manifest with version $VERSION"
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.metainfo.xml
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak/com.karmaa.termix.metainfo.xml

24
homebrew/termix.rb Normal file
View File

@@ -0,0 +1,24 @@
cask "termix" do
version "VERSION_PLACEHOLDER"
sha256 "CHECKSUM_PLACEHOLDER"
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg"
name "Termix"
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
homepage "https://github.com/Termix-SSH/Termix"
livecheck do
url :url
strategy :github_latest
end
app "Termix.app"
zap trash: [
"~/Library/Application Support/termix",
"~/Library/Caches/com.karmaa.termix",
"~/Library/Caches/com.karmaa.termix.ShipIt",
"~/Library/Preferences/com.karmaa.termix.plist",
"~/Library/Saved Application State/com.karmaa.termix.savedState",
]
end

View File

@@ -5,6 +5,36 @@
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termix</title>
<style>
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.skinny-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4a4a4a #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.skinny-scrollbar::-webkit-scrollbar-track {
background: #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar-thumb {
background-color: #4a4a4a;
border-radius: 3px;
border: 1px solid #1e1e21;
}
</style>
</head>
<body>
<div id="root"></div>

5876
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,30 @@
{
"name": "termix",
"private": true,
"version": "1.7.3",
"version": "1.8.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
"type": "module",
"scripts": {
"clean": "npx prettier . --write",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"type-check": "tsc --noEmit",
"dev": "vite",
"build": "vite build && tsc -p tsconfig.node.json",
"build:backend": "tsc -p tsconfig.node.json",
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
"build:win-portable": "npm run build && electron-builder --win --dir",
"build:win-installer": "npm run build && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && electron-builder --linux --dir",
"build:linux-appimage": "npm run build && electron-builder --linux AppImage",
"build:linux-targz": "npm run build && electron-builder --linux tar.gz",
"test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js",
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
"build:mac": "npm run build && electron-builder --mac --universal"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.7",
@@ -40,11 +44,12 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.11",
"@tailwindcss/vite": "^4.1.14",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/jszip": "^3.4.0",
@@ -95,16 +100,21 @@
"react-simple-keyboard": "^3.8.120",
"react-syntax-highlighter": "^15.6.6",
"react-xtermjs": "^1.0.10",
"recharts": "^3.2.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"wait-on": "^9.0.1",
"ws": "^8.18.3",
"zod": "^4.0.5"
},
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@electron/notarize": "^2.5.0",
"@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
@@ -115,7 +125,7 @@
"@types/react-dom": "^19.1.6",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"electron": "^38.0.0",
"electron-builder": "^26.0.12",
@@ -123,9 +133,19 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.3",
"prettier": "3.6.2",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.5"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write"
],
"*.{json,css,md}": [
"prettier --write"
]
}
}

BIN
public/icon-mac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 307 KiB

245
src/backend/dashboard.ts Normal file
View File

@@ -0,0 +1,245 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { getDb } from "./database/db/index.js";
import { recentActivity, sshData } from "./database/db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
import type { AuthenticatedRequest } from "../types/index.js";
const app = express();
const authManager = AuthManager.getInstance();
const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000; // 1 second window
app.use(
cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
];
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
if (origin.startsWith("https://")) {
return callback(null, true);
}
if (origin.startsWith("http://")) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"User-Agent",
"X-Electron-App",
],
}),
);
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use(authManager.createAuthMiddleware());
app.get("/uptime", async (req, res) => {
try {
const uptimeMs = Date.now() - serverStartTime;
const uptimeSeconds = Math.floor(uptimeMs / 1000);
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
res.json({
uptimeMs,
uptimeSeconds,
formatted: `${days}d ${hours}h ${minutes}m`,
});
} catch (err) {
dashboardLogger.error("Failed to get uptime", err);
res.status(500).json({ error: "Failed to get uptime" });
}
});
app.get("/activity/recent", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const limit = Number(req.query.limit) || 20;
const activities = await SimpleDBOps.select(
getDb()
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit),
"recent_activity",
userId,
);
res.json(activities);
} catch (err) {
dashboardLogger.error("Failed to get recent activity", err);
res.status(500).json({ error: "Failed to get recent activity" });
}
});
app.post("/activity/log", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { type, hostId, hostName } = req.body;
if (!type || !hostId || !hostName) {
return res.status(400).json({
error: "Missing required fields: type, hostId, hostName",
});
}
if (type !== "terminal" && type !== "file_manager") {
return res.status(400).json({
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
});
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
return res.json({
message: "Activity already logged recently (rate limited)",
});
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
const entriesToDelete: string[] = [];
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) {
entriesToDelete.push(key);
}
}
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
}
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return res.status(404).json({ error: "Host not found" });
}
const result = (await SimpleDBOps.insert(
recentActivity,
"recent_activity",
{
userId,
type,
hostId,
hostName,
},
userId,
)) as unknown as { id: number };
const allActivities = await SimpleDBOps.select(
getDb()
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp)),
"recent_activity",
userId,
);
if (allActivities.length > 100) {
const toDelete = allActivities.slice(100);
for (const activity of toDelete) {
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
}
}
res.json({ message: "Activity logged", id: result.id });
} catch (err) {
dashboardLogger.error("Failed to log activity", err);
res.status(500).json({ error: "Failed to log activity" });
}
});
app.delete("/activity/reset", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
await SimpleDBOps.delete(
recentActivity,
"recent_activity",
eq(recentActivity.userId, userId),
);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
userId,
});
res.json({ message: "Recent activity cleared" });
} catch (err) {
dashboardLogger.error("Failed to reset activity", err);
res.status(500).json({ error: "Failed to reset activity" });
}
});
const PORT = 30006;
app.listen(PORT, async () => {
try {
await authManager.initialize();
} catch (err) {
dashboardLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
});

View File

@@ -6,6 +6,7 @@ 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 snippetsRoutes from "./routes/snippets.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -31,6 +32,12 @@ import {
sshCredentialUsage,
settings,
} from "./db/schema.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { getDb } from "./db/index.js";
import Database from "better-sqlite3";
@@ -53,6 +60,10 @@ app.use(
"http://127.0.0.1:3000",
];
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
if (origin.startsWith("https://")) {
return callback(null, true);
}
@@ -61,10 +72,6 @@ app.use(
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
@@ -74,6 +81,8 @@ app.use(
"Authorization",
"User-Agent",
"X-Electron-App",
"Accept",
"Origin",
],
}),
);
@@ -105,17 +114,11 @@ const upload = multer({
},
});
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set(key: string, data: any): void {
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
@@ -124,7 +127,7 @@ class GitHubCache {
});
}
get(key: string): any | null {
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
@@ -135,7 +138,7 @@ class GitHubCache {
return null;
}
return entry.data;
return entry.data as T;
}
}
@@ -145,34 +148,16 @@ const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
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;
name: string;
size: number;
download_count: number;
browser_download_url: string;
}>;
prerelease: boolean;
draft: boolean;
}
async function fetchGitHubAPI(
async function fetchGitHubAPI<T>(
endpoint: string,
cacheKey: string,
): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
): Promise<GitHubAPIResponse<T>> {
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
if (cachedEntry) {
return {
data: cachedData,
data: cachedEntry.data,
cached: true,
cache_age: Date.now() - cachedData.timestamp,
cache_age: Date.now() - cachedEntry.timestamp,
};
}
@@ -191,8 +176,13 @@ async function fetchGitHubAPI(
);
}
const data = await response.json();
githubCache.set(cacheKey, data);
const data = (await response.json()) as T;
const cacheData: CacheEntry<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
};
githubCache.set(cacheKey, cacheData);
return {
data: data,
@@ -257,7 +247,7 @@ app.get("/version", authenticateJWT, async (req, res) => {
localVersion = foundVersion;
break;
}
} catch (error) {
} catch {
continue;
}
}
@@ -272,7 +262,7 @@ app.get("/version", authenticateJWT, async (req, res) => {
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI(
const releaseData = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
@@ -323,12 +313,12 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
const releasesData = await fetchGitHubAPI<GitHubRelease[]>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey,
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
const rssItems = releasesData.data.map((release) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
@@ -372,7 +362,6 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
app.get("/encryption/status", requireAdmin, async (req, res) => {
try {
const authManager = AuthManager.getInstance();
const securityStatus = {
initialized: true,
system: { hasSecret: true, isValid: true },
@@ -417,8 +406,6 @@ app.post("/encryption/initialize", requireAdmin, async (req, res) => {
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
try {
const authManager = AuthManager.getInstance();
apiLogger.warn("System JWT secret regenerated via API", {
operation: "jwt_regenerate_api",
});
@@ -440,8 +427,6 @@ app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
try {
const authManager = AuthManager.getInstance();
apiLogger.warn("JWT secret regenerated via API", {
operation: "jwt_secret_regenerate_api",
});
@@ -462,7 +447,7 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
app.post("/database/export", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!password) {
@@ -695,7 +680,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.authType,
decrypted.password || null,
decrypted.key || null,
decrypted.keyPassword || null,
decrypted.key_password || null,
decrypted.keyType || null,
decrypted.autostartPassword || null,
decrypted.autostartKey || null,
@@ -738,9 +723,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.username,
decrypted.password || null,
decrypted.key || null,
decrypted.privateKey || null,
decrypted.publicKey || null,
decrypted.keyPassword || null,
decrypted.private_key || null,
decrypted.public_key || null,
decrypted.key_password || null,
decrypted.keyType || null,
decrypted.detectedKeyType || null,
decrypted.usageCount || 0,
@@ -916,19 +901,40 @@ app.post(
return res.status(400).json({ error: "No file uploaded" });
}
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
const mainDb = getDb();
if (!password) {
return res.status(400).json({
error: "Password required for import",
code: "PASSWORD_REQUIRED",
});
const userRecords = await mainDb
.select()
.from(users)
.where(eq(users.id, userId));
if (!userRecords || userRecords.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const unlocked = await authManager.authenticateUser(userId, password);
if (!unlocked) {
return res.status(401).json({ error: "Invalid password" });
const isOidcUser = !!userRecords[0].is_oidc;
if (!isOidcUser) {
if (!password) {
return res.status(400).json({
error: "Password required for import",
code: "PASSWORD_REQUIRED",
});
}
const unlocked = await authManager.authenticateUser(userId, password);
if (!unlocked) {
return res.status(401).json({ error: "Invalid password" });
}
} else if (!DataCrypto.getUserDataKey(userId)) {
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
if (!oidcUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
}
}
apiLogger.info("Importing SQLite data", {
@@ -939,7 +945,13 @@ app.post(
mimetype: req.file.mimetype,
});
const userDataKey = DataCrypto.getUserDataKey(userId);
let userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey && isOidcUser) {
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
if (oidcUnlocked) {
userDataKey = DataCrypto.getUserDataKey(userId);
}
}
if (!userDataKey) {
throw new Error("User data not unlocked");
}
@@ -968,7 +980,7 @@ app.post(
try {
importDb = new Database(req.file.path, { readonly: true });
const tables = importDb
importDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all();
} catch (sqliteError) {
@@ -993,8 +1005,6 @@ app.post(
};
try {
const mainDb = getDb();
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
@@ -1059,7 +1069,7 @@ app.post(
);
}
}
} catch (tableError) {
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1120,7 +1130,7 @@ app.post(
);
}
}
} catch (tableError) {
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
@@ -1191,7 +1201,7 @@ app.post(
);
}
}
} catch (tableError) {
} catch {
apiLogger.info(`${table} table not found in import file, skipping`);
}
}
@@ -1229,7 +1239,7 @@ app.post(
);
}
}
} catch (tableError) {
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
@@ -1270,7 +1280,7 @@ app.post(
);
}
}
} catch (tableError) {
} catch {
apiLogger.info("settings table not found in import file, skipping");
}
} else {
@@ -1288,7 +1298,7 @@ app.post(
try {
fs.unlinkSync(req.file.path);
} catch (cleanupError) {
} catch {
apiLogger.warn("Failed to clean up uploaded file", {
operation: "file_cleanup_warning",
filePath: req.file.path,
@@ -1314,7 +1324,7 @@ app.post(
if (req.file?.path && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch (cleanupError) {
} catch {
apiLogger.warn("Failed to clean up uploaded file after error", {
operation: "file_cleanup_error",
filePath: req.file.path,
@@ -1324,7 +1334,7 @@ app.post(
apiLogger.error("SQLite import failed", error, {
operation: "sqlite_import_api_failed",
userId: (req as any).userId,
userId: (req as AuthenticatedRequest).userId,
});
res.status(500).json({
error: "Failed to import SQLite data",
@@ -1336,12 +1346,8 @@ app.post(
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).userId;
const {
format = "encrypted",
scope = "user_data",
includeCredentials = true,
} = req.body;
const userId = (req as AuthenticatedRequest).userId;
const { scope = "user_data", includeCredentials = true } = req.body;
const exportData = await UserDataExport.exportUserData(userId, {
format: "encrypted",
@@ -1411,13 +1417,14 @@ app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use(
(
err: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction,
_next: express.NextFunction,
) => {
apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
@@ -1430,7 +1437,6 @@ app.use(
);
const HTTP_PORT = 30001;
const HTTPS_PORT = process.env.SSL_PORT || 8443;
async function initializeSecurity() {
try {
@@ -1443,13 +1449,6 @@ async function initializeSecurity() {
if (!isValid) {
throw new Error("Security system validation failed");
}
const securityStatus = {
initialized: true,
system: { hasSecret: true, isValid: true },
activeSessions: {},
activeSessionCount: 0,
};
} catch (error) {
databaseLogger.error("Failed to initialize security system", error, {
operation: "security_init_error",
@@ -1481,13 +1480,13 @@ app.get(
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
} catch (error) {}
} catch {}
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
} catch (error) {}
} catch {}
}
res.json({

View File

@@ -12,10 +12,6 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
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 });
}
@@ -23,7 +19,7 @@ const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
const dbPath = path.join(dataDir, "db.sqlite");
const encryptedDbPath = `${dbPath}.encrypted`;
let actualDbPath = ":memory:";
const actualDbPath = ":memory:";
let memoryDatabase: Database.Database;
let isNewDatabase = false;
let sqlite: Database.Database;
@@ -31,7 +27,7 @@ let sqlite: Database.Database;
async function initializeDatabaseAsync(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance();
const dbKey = await systemCrypto.getDatabaseKey();
await systemCrypto.getDatabaseKey();
if (enableFileEncryption) {
try {
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
@@ -39,6 +35,13 @@ async function initializeDatabaseAsync(): Promise<void> {
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
memoryDatabase = new Database(decryptedBuffer);
try {
const sessionCount = memoryDatabase
.prepare("SELECT COUNT(*) as count FROM sessions")
.get() as { count: number };
} catch (countError) {
}
} else {
const migration = new DatabaseMigration(dataDir);
const migrationStatus = migration.checkMigrationStatus();
@@ -145,6 +148,18 @@ async function initializeCompleteDatabase(): Promise<void> {
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -165,6 +180,12 @@ async function initializeCompleteDatabase(): Promise<void> {
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
stats_config TEXT,
terminal_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
@@ -242,8 +263,39 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
host_id INTEGER NOT NULL,
host_name TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
`);
try {
sqlite.prepare("DELETE FROM sessions").run();
} catch (e) {
databaseLogger.warn("Could not clear sessions on startup", {
operation: "db_init_session_cleanup_failed",
error: e,
});
}
migrateSchema();
try {
@@ -263,6 +315,24 @@ async function initializeCompleteDatabase(): Promise<void> {
error: e,
});
}
try {
const row = sqlite
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
.get();
if (!row) {
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
)
.run();
}
} catch (e) {
databaseLogger.warn("Could not initialize allow_password_login setting", {
operation: "db_init",
error: e,
});
}
}
const addColumnIfNotExists = (
@@ -277,7 +347,7 @@ const addColumnIfNotExists = (
FROM ${table} LIMIT 1`,
)
.get();
} catch (e) {
} catch {
try {
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
@@ -351,7 +421,10 @@ const migrateSchema = () => {
"updated_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists(
"ssh_data",
"credential_id",
@@ -361,6 +434,8 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
@@ -370,6 +445,33 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
try {
sqlite
.prepare("SELECT id FROM sessions LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create sessions table", {
operation: "schema_migration",
error: createError,
});
}
}
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
@@ -385,6 +487,13 @@ async function saveMemoryDatabaseToFile() {
fs.mkdirSync(dataDir, { recursive: true });
}
try {
const sessionCount = memoryDatabase
.prepare("SELECT COUNT(*) as count FROM sessions")
.get() as { count: number };
} catch (countError) {
}
if (enableFileEncryption) {
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
@@ -476,21 +585,25 @@ async function cleanupDatabase() {
for (const file of files) {
try {
fs.unlinkSync(path.join(tempDir, file));
} catch {}
} catch {
}
}
try {
fs.rmdirSync(tempDir);
} catch {}
} catch {
}
}
} catch (error) {}
} catch {
}
}
process.on("exit", () => {
if (sqlite) {
try {
sqlite.close();
} catch {}
} catch {
}
}
});

View File

@@ -30,6 +30,23 @@ export const settings = sqliteTable("settings", {
value: text("value").notNull(),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshData = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -43,6 +60,7 @@ export const sshData = sqliteTable("ssh_data", {
tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
forceKeyboardInteractive: text("force_keyboard_interactive"),
password: text("password"),
key: text("key", { length: 8192 }),
@@ -65,6 +83,8 @@ export const sshData = sqliteTable("ssh_data", {
.notNull()
.default(true),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -172,3 +192,34 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippets = sqliteTable("snippets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
content: text("content").notNull(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const recentActivity = sqliteTable("recent_activity", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
hostName: text("host_name").notNull(),
timestamp: text("timestamp")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -1,3 +1,8 @@
import type {
AuthenticatedRequest,
CacheEntry,
TermixAlert,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { dismissedAlerts } from "../db/schema.js";
@@ -6,17 +11,11 @@ import fetch from "node-fetch";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set(key: string, data: any): void {
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
@@ -25,7 +24,7 @@ class AlertCache {
});
}
get(key: string): any | null {
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
@@ -36,7 +35,7 @@ class AlertCache {
return null;
}
return entry.data;
return entry.data as T;
}
}
@@ -47,20 +46,9 @@ const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "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;
}
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = "termix_alerts";
const cachedData = alertCache.get(cacheKey);
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
if (cachedData) {
return cachedData;
}
@@ -115,7 +103,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
// GET /alerts
router.get("/", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const allAlerts = await fetchAlertsFromGitHub();
@@ -148,7 +136,7 @@ router.get("/", authenticateJWT, async (req, res) => {
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
authLogger.warn("Missing alertId in dismiss request", { userId });
@@ -170,7 +158,7 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
return res.status(409).json({ error: "Alert already dismissed" });
}
const result = await db.insert(dismissedAlerts).values({
await db.insert(dismissedAlerts).values({
userId,
alertId,
});
@@ -186,7 +174,7 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
// GET /alerts/dismissed/:userId
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const dismissedAlertRecords = await db
.select({
@@ -211,7 +199,7 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
return res.status(400).json({ error: "Alert ID is required" });

View File

@@ -1,16 +1,15 @@
import type { AuthenticatedRequest } from "../../../types/index.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 type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
parseSSHKey,
parsePublicKey,
detectKeyType,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import crypto from "crypto";
@@ -29,7 +28,11 @@ function generateSSHKeyPair(
} {
try {
let ssh2Type = keyType;
const options: any = {};
const options: {
bits?: number;
passphrase?: string;
cipher?: string;
} = {};
if (keyType === "ssh-rsa") {
ssh2Type = "rsa";
@@ -46,6 +49,7 @@ function generateSSHKeyPair(
options.cipher = "aes128-cbc";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return {
@@ -64,7 +68,7 @@ function generateSSHKeyPair(
const router = express.Router();
function isNonEmptyString(val: any): val is string {
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
@@ -79,7 +83,7 @@ router.post(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const {
name,
description,
@@ -226,7 +230,7 @@ router.get(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential fetch");
@@ -259,7 +263,7 @@ router.get(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential folder fetch");
@@ -297,7 +301,7 @@ router.get(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
@@ -328,19 +332,19 @@ router.get(
const output = formatCredentialOutput(credential);
if (credential.password) {
(output as any).password = credential.password;
output.password = credential.password;
}
if (credential.key) {
(output as any).key = credential.key;
output.key = credential.key;
}
if (credential.private_key) {
(output as any).privateKey = credential.private_key;
output.privateKey = credential.private_key;
}
if (credential.public_key) {
(output as any).publicKey = credential.public_key;
output.publicKey = credential.public_key;
}
if (credential.key_password) {
(output as any).keyPassword = credential.key_password;
output.keyPassword = credential.key_password;
}
res.json(output);
@@ -361,7 +365,7 @@ router.put(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const updateData = req.body;
@@ -385,7 +389,7 @@ router.put(
return res.status(404).json({ error: "Credential not found" });
}
const updateFields: any = {};
const updateFields: Record<string, string | null | undefined> = {};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
@@ -497,7 +501,7 @@ router.delete(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
@@ -596,7 +600,7 @@ router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { id: credentialId, hostId } = req.params;
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
@@ -629,8 +633,8 @@ router.post(
.update(sshData)
.set({
credentialId: parseInt(credentialId),
username: credential.username,
authType: credential.auth_type || credential.authType,
username: credential.username as string,
authType: (credential.auth_type || credential.authType) as string,
password: null,
key: null,
key_password: null,
@@ -675,7 +679,7 @@ router.get(
"/:id/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { id: credentialId } = req.params;
if (!isNonEmptyString(userId) || !credentialId) {
@@ -707,7 +711,9 @@ router.get(
},
);
function formatCredentialOutput(credential: any): any {
function formatCredentialOutput(
credential: Record<string, unknown>,
): Record<string, unknown> {
return {
id: credential.id,
name: credential.name,
@@ -731,7 +737,9 @@ function formatCredentialOutput(credential: any): any {
};
}
function formatSSHHostOutput(host: any): any {
function formatSSHHostOutput(
host: Record<string, unknown>,
): Record<string, unknown> {
return {
id: host.id,
userId: host.userId,
@@ -751,7 +759,7 @@ function formatSSHHostOutput(host: any): any {
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
? JSON.parse(host.tunnelConnections as string)
: [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
@@ -766,7 +774,7 @@ router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
@@ -970,7 +978,7 @@ router.post(
try {
let privateKeyObj;
let parseAttempts = [];
const parseAttempts = [];
try {
privateKeyObj = crypto.createPrivateKey({
@@ -1093,7 +1101,9 @@ router.post(
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
}
} catch (sshError) {}
} catch {
// Ignore validation errors
}
const response = {
success: true,
@@ -1117,15 +1127,15 @@ router.post(
);
async function deploySSHKeyToHost(
hostConfig: any,
hostConfig: Record<string, unknown>,
publicKey: string,
credentialData: any,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_credentialData: Record<string, unknown>,
): Promise<{ success: boolean; message?: string; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
let connectionTimeout: NodeJS.Timeout;
connectionTimeout = setTimeout(() => {
const connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 120000);
@@ -1158,7 +1168,9 @@ async function deploySSHKeyToHost(
}
});
stream.on("data", (data) => {});
stream.on("data", () => {
// Ignore output
});
},
);
});
@@ -1175,7 +1187,9 @@ async function deploySSHKeyToHost(
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch (e) {}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
@@ -1202,7 +1216,7 @@ async function deploySSHKeyToHost(
output += data.toString();
});
stream.on("close", (code) => {
stream.on("close", () => {
clearTimeout(checkTimeout);
const exists = output.trim() === "0";
resolveCheck(exists);
@@ -1229,7 +1243,9 @@ async function deploySSHKeyToHost(
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch (e) {}
} catch {
// Ignore parse errors
}
const escapedKey = actualPublicKey
.replace(/\\/g, "\\\\")
@@ -1269,7 +1285,9 @@ async function deploySSHKeyToHost(
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch (e) {}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
@@ -1295,7 +1313,7 @@ async function deploySSHKeyToHost(
output += data.toString();
});
stream.on("close", (code) => {
stream.on("close", () => {
clearTimeout(verifyTimeout);
const verified = output.trim() === "0";
resolveVerify(verified);
@@ -1356,7 +1374,7 @@ async function deploySSHKeyToHost(
});
try {
const connectionConfig: any = {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
@@ -1403,14 +1421,15 @@ async function deploySSHKeyToHost(
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
if (
!hostConfig.privateKey.includes("-----BEGIN") ||
!hostConfig.privateKey.includes("-----END")
!privateKey.includes("-----BEGIN") ||
!privateKey.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = hostConfig.privateKey
const cleanKey = privateKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
@@ -1465,7 +1484,7 @@ router.post(
}
try {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({
success: false,
@@ -1521,7 +1540,7 @@ router.post(
const hostData = targetHost[0];
let hostConfig = {
const hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
@@ -1532,7 +1551,7 @@ router.post(
};
if (hostData.authType === "credential" && hostData.credentialId) {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(400).json({
success: false,
@@ -1546,7 +1565,7 @@ router.post(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId))
.where(eq(sshCredentials.id, hostData.credentialId as number))
.limit(1),
"ssh_credentials",
userId,
@@ -1571,7 +1590,7 @@ router.post(
error: "Host credential not found",
});
}
} catch (error) {
} catch {
return res.status(500).json({
success: false,
error: "Failed to resolve host credentials",
@@ -1581,7 +1600,7 @@ router.post(
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData.publicKey,
credData.publicKey as string,
credData,
);

View File

@@ -0,0 +1,260 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { snippets } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Get all snippets for the authenticated user
// GET /snippets
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippets fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(desc(snippets.updatedAt));
res.json(result);
} catch (err) {
authLogger.error("Failed to fetch snippets", err);
res.status(500).json({ error: "Failed to fetch snippets" });
}
},
);
// Get a specific snippet by ID
// GET /snippets/:id
router.get(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const snippetId = parseInt(id, 10);
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
userId,
id,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const result = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (result.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
res.json(result[0]);
} catch (err) {
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch snippet",
});
}
},
);
// Create a new snippet
// POST /snippets
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, content, description } = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(content)
) {
authLogger.warn("Invalid snippet creation data validation failed", {
operation: "snippet_create",
userId,
hasName: !!name,
hasContent: !!content,
});
return res.status(400).json({ error: "Name and content are required" });
}
try {
const insertData = {
userId,
name: name.trim(),
content: content.trim(),
description: description?.trim() || null,
};
const result = await db.insert(snippets).values(insertData).returning();
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
operation: "snippet_create_success",
userId,
snippetId: result[0].id,
name,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create snippet",
});
}
},
);
// Update a snippet
// PUT /snippets/:id
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql.raw>;
name: string;
content: string;
description: string | null;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.content !== undefined)
updateFields.content = updateData.content.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const updated = await db
.select()
.from(snippets)
.where(eq(snippets.id, parseInt(id)));
authLogger.success(
`Snippet updated: ${updated[0].name} by user ${userId}`,
{
operation: "snippet_update_success",
userId,
snippetId: parseInt(id),
name: updated[0].name,
},
);
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update snippet",
});
}
},
);
// Delete a snippet
// DELETE /snippets/:id
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet delete");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
await db
.delete(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
authLogger.success(
`Snippet deleted: ${existing[0].name} by user ${userId}`,
{
operation: "snippet_delete_success",
userId,
snippetId: parseInt(id),
name: existing[0].name,
},
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete snippet",
});
}
},
);
export default router;

View File

@@ -1,3 +1,4 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
@@ -9,8 +10,7 @@ import {
fileManagerShortcuts,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import type { Request, Response } from "express";
import multer from "multer";
import { sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
@@ -23,11 +23,11 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
function isNonEmptyString(value: any): value is string {
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isValidPort(port: any): port is number {
function isValidPort(port: unknown): port is number {
return typeof port === "number" && port > 0 && port <= 65535;
}
@@ -75,7 +75,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: any) => tunnel.autoStart,
(tunnel: Record<string, unknown>) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
@@ -100,7 +100,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: any) => tunnel.autoStart,
(tunnel: Record<string, unknown>) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
@@ -184,8 +184,8 @@ router.post(
requireDataAccess,
upload.single("key"),
async (req: Request, res: Response) => {
const userId = (req as any).userId;
let hostData: any;
const userId = (req as AuthenticatedRequest).userId;
let hostData: Record<string, unknown>;
if (req.headers["content-type"]?.includes("multipart/form-data")) {
if (req.body.data) {
@@ -234,6 +234,9 @@ router.post(
enableFileManager,
defaultPath,
tunnelConnections,
statsConfig,
terminalConfig,
forceKeyboardInteractive,
} = hostData;
if (
!isNonEmptyString(userId) ||
@@ -251,7 +254,7 @@ router.post(
}
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = {
const sshDataObj: Record<string, unknown> = {
userId: userId,
name,
folder: folder || null,
@@ -269,6 +272,9 @@ router.post(
: null,
enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
};
if (effectiveAuthType === "password") {
@@ -320,9 +326,12 @@ router.post(
enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections
? JSON.parse(createdHost.tunnelConnections)
? JSON.parse(createdHost.tunnelConnections as string)
: [],
enableFileManager: !!createdHost.enableFileManager,
statsConfig: createdHost.statsConfig
? JSON.parse(createdHost.statsConfig as string)
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -332,7 +341,7 @@ router.post(
{
operation: "host_create_success",
userId,
hostId: createdHost.id,
hostId: createdHost.id as number,
name,
ip,
port,
@@ -363,8 +372,8 @@ router.put(
upload.single("key"),
async (req: Request, res: Response) => {
const hostId = req.params.id;
const userId = (req as any).userId;
let hostData: any;
const userId = (req as AuthenticatedRequest).userId;
let hostData: Record<string, unknown>;
if (req.headers["content-type"]?.includes("multipart/form-data")) {
if (req.body.data) {
@@ -415,6 +424,9 @@ router.put(
enableFileManager,
defaultPath,
tunnelConnections,
statsConfig,
terminalConfig,
forceKeyboardInteractive,
} = hostData;
if (
!isNonEmptyString(userId) ||
@@ -434,7 +446,7 @@ router.put(
}
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = {
const sshDataObj: Record<string, unknown> = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -451,6 +463,9 @@ router.put(
: null,
enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
};
if (effectiveAuthType === "password") {
@@ -520,9 +535,12 @@ router.put(
enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections
? JSON.parse(updatedHost.tunnelConnections)
? JSON.parse(updatedHost.tunnelConnections as string)
: [],
enableFileManager: !!updatedHost.enableFileManager,
statsConfig: updatedHost.statsConfig
? JSON.parse(updatedHost.statsConfig as string)
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -559,7 +577,7 @@ router.put(
// 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;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for SSH data fetch", {
operation: "host_fetch",
@@ -575,7 +593,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
);
const result = await Promise.all(
data.map(async (row: any) => {
data.map(async (row: Record<string, unknown>) => {
const baseHost = {
...row,
tags:
@@ -588,9 +606,16 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
? JSON.parse(row.tunnelConnections)
? JSON.parse(row.tunnelConnections as string)
: [],
enableFileManager: !!row.enableFileManager,
statsConfig: row.statsConfig
? JSON.parse(row.statsConfig as string)
: undefined,
terminalConfig: row.terminalConfig
? JSON.parse(row.terminalConfig as string)
: undefined,
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
};
return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -614,7 +639,7 @@ router.get(
authenticateJWT,
async (req: Request, res: Response) => {
const hostId = req.params.id;
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn("Invalid userId or hostId for SSH host fetch by ID", {
@@ -655,6 +680,13 @@ router.get(
? JSON.parse(host.tunnelConnections)
: [],
enableFileManager: !!host.enableFileManager,
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig)
: undefined,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
};
res.json((await resolveHostCredentials(result)) || result);
@@ -677,7 +709,7 @@ router.get(
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = req.params.id;
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !hostId) {
return res.status(400).json({ error: "Invalid userId or hostId" });
@@ -711,7 +743,7 @@ router.get(
authType: resolvedHost.authType,
password: resolvedHost.password || null,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyPassword: resolvedHost.key_password || null,
keyType: resolvedHost.keyType || null,
folder: resolvedHost.folder,
tags:
@@ -724,7 +756,7 @@ router.get(
enableFileManager: !!resolvedHost.enableFileManager,
defaultPath: resolvedHost.defaultPath,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections)
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
};
@@ -752,7 +784,7 @@ router.delete(
"/db/host/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const hostId = req.params.id;
if (!isNonEmptyString(userId) || !hostId) {
@@ -816,7 +848,7 @@ router.delete(
),
);
const result = await db
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -851,7 +883,7 @@ router.get(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const hostId = req.query.hostId
? parseInt(req.query.hostId as string)
: null;
@@ -893,7 +925,7 @@ router.post(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
@@ -942,8 +974,8 @@ router.delete(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { hostId, path, name } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file deletion");
@@ -975,7 +1007,7 @@ router.get(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const hostId = req.query.hostId
? parseInt(req.query.hostId as string)
: null;
@@ -1016,7 +1048,7 @@ router.post(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
@@ -1062,8 +1094,8 @@ router.delete(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { hostId, path, name } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file deletion");
@@ -1095,7 +1127,7 @@ router.get(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const hostId = req.query.hostId
? parseInt(req.query.hostId as string)
: null;
@@ -1136,7 +1168,7 @@ router.post(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
@@ -1182,8 +1214,8 @@ router.delete(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { hostId, path, name } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut deletion");
@@ -1209,21 +1241,26 @@ router.delete(
},
);
async function resolveHostCredentials(host: any): Promise<any> {
async function resolveHostCredentials(
host: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
if (host.credentialId && host.userId) {
const credentialId = host.credentialId as number;
const userId = host.userId as string;
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId),
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
host.userId,
userId,
);
if (credentials.length > 0) {
@@ -1239,6 +1276,7 @@ async function resolveHostCredentials(host: any): Promise<any> {
};
}
}
const result = { ...host };
if (host.key_password !== undefined) {
if (result.keyPassword === undefined) {
@@ -1261,7 +1299,7 @@ router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(userId) || !oldName || !newName) {
@@ -1326,7 +1364,7 @@ router.post(
"/bulk-import",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { hosts } = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) {
@@ -1398,7 +1436,7 @@ router.post(
continue;
}
const sshDataObj: any = {
const sshDataObj: Record<string, unknown> = {
userId: userId,
name: hostData.name || `${hostData.username}@${hostData.ip}`,
folder: hostData.folder || "Default",
@@ -1411,7 +1449,7 @@ router.post(
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
key_password:
keyPassword:
hostData.authType === "key"
? hostData.keyPassword || hostData.key_password || null
: null,
@@ -1425,6 +1463,9 @@ router.post(
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
@@ -1455,7 +1496,7 @@ router.post(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
@@ -1519,7 +1560,7 @@ router.post(
const tunnelConnections = JSON.parse(config.tunnelConnections);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: any) => {
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
@@ -1567,7 +1608,7 @@ router.post(
}
}
const updateResult = await db
await db
.update(sshData)
.set({
autostartPassword: decryptedConfig.password || null,
@@ -1608,7 +1649,7 @@ router.delete(
"/autostart/disable",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
@@ -1624,7 +1665,7 @@ router.delete(
}
try {
const result = await db
await db
.update(sshData)
.set({
autostartPassword: null,
@@ -1654,7 +1695,7 @@ router.get(
"/autostart/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = await db

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,15 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
function isExecutableFile(permissions: string, fileName: string): boolean {
const hasExecutePermission =
@@ -94,7 +96,24 @@ interface SSHSession {
timeout?: NodeJS.Timeout;
}
interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
}
const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
@@ -153,9 +172,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keyPassword,
authType,
credentialId,
userProvidedPassword,
forceKeyboardInteractive,
} = req.body;
const userId = (req as any).userId;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
fileLogger.error("SSH connection rejected: no authenticated user", {
@@ -235,40 +256,68 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
);
}
const config: any = {
const config: Record<string, unknown> = {
host: ip,
port: port || 22,
port,
username,
readyTimeout: 60000,
tryKeyboard: true,
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: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"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",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -277,12 +326,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password &&
resolvedCredentials.password.trim()
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey &&
resolvedCredentials.sshKey.trim()
@@ -313,6 +356,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
});
return res.status(400).json({ error: "Invalid SSH key format" });
}
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) {
return res
.status(400)
.json({ error: "Password required for password authentication" });
}
if (!forceKeyboardInteractive) {
config.password = resolvedCredentials.password;
}
} else if (resolvedCredentials.authType === "none") {
} else {
fileLogger.warn(
"No valid authentication method provided for file manager",
@@ -342,6 +396,48 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
scheduleSessionCleanup(sessionId);
res.json({ status: "success", message: "SSH connection established" });
if (hostId && userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{
type: "file_manager",
hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
operation: "activity_log_error",
userId,
hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
});
client.on("error", (err) => {
@@ -356,7 +452,19 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
username,
error: err.message,
});
res.status(500).json({ status: "error", message: err.message });
if (
resolvedCredentials.authType === "none" &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
res.json({
status: "auth_required",
reason: "no_keyboard",
});
} else {
res.status(500).json({ status: "error", message: err.message });
}
});
client.on("close", () => {
@@ -364,9 +472,324 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
cleanupSession(sessionId);
});
let keyboardInteractiveResponded = false;
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const promptTexts = prompts.map((p) => p.prompt);
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
});
} else {
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (
resolvedCredentials.authType === "none" &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({
status: "auth_required",
reason: "no_keyboard",
});
return;
}
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
},
);
client.connect(config);
});
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
fileLogger.error("TOTP verification rejected: no authenticated user", {
operation: "file_totp_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId || !totpCode) {
return res.status(400).json({ error: "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
fileLogger.warn("TOTP session not found or expired", {
operation: "file_totp_verify",
sessionId,
userId,
availableSessions: Object.keys(pendingTOTPSessions),
});
return res
.status(404)
.json({ error: "TOTP session expired. Please reconnect." });
}
if (Date.now() - session.createdAt > 180000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify",
sessionId,
userId,
age: Date.now() - session.createdAt,
});
return res
.status(408)
.json({ error: "TOTP session timeout. Please reconnect." });
}
const responses = (session.prompts || []).map((p, index) => {
if (index === session.totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && session.resolvedPassword) {
return session.resolvedPassword;
}
return "";
});
let responseSent = false;
let responseTimeout: NodeJS.Timeout;
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "TOTP verified, SSH connection established",
});
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
),
),
"ssh_data",
session.userId!,
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${session.username}@${session.ip}:${session.port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{
type: "file_manager",
hostId: session.hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
},
},
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity (TOTP)", {
operation: "activity_log_error",
userId: session.userId,
hostId: session.hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
}, 200);
});
session.client.once("error", (err) => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
fileLogger.error("TOTP verification failed", {
operation: "file_totp_verify",
sessionId,
userId,
error: err.message,
});
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
fileLogger.warn("TOTP verification timeout", {
operation: "file_totp_verify",
sessionId,
userId,
});
res.status(408).json({ error: "TOTP verification timeout" });
}
}, 60000);
session.finish(responses);
});
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);
@@ -455,13 +878,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const linkCount = parts[1];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
let nameStartIndex = 8;
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
@@ -694,7 +1116,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
});
app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const { sessionId, path: filePath, content, hostId, userId } = req.body;
const { sessionId, path: filePath, content } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -881,14 +1303,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
});
app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
const {
sessionId,
path: filePath,
content,
fileName,
hostId,
userId,
} = req.body;
const { sessionId, path: filePath, content, fileName } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1022,8 +1437,6 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
}
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"`;
@@ -1088,13 +1501,11 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
});
});
} 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) => {
chunks.forEach((chunk) => {
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
});
@@ -1177,14 +1588,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
});
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const {
sessionId,
path: filePath,
fileName,
content = "",
hostId,
userId,
} = req.body;
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1285,7 +1689,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
});
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName, hostId, userId } = req.body;
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1386,7 +1790,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
});
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory, hostId, userId } = req.body;
const { sessionId, path: itemPath, isDirectory } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1488,7 +1892,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
});
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const { sessionId, oldPath, newName, hostId, userId } = req.body;
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1596,7 +2000,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
});
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
@@ -1985,7 +2389,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
});
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath, hostId, userId } = req.body;
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
@@ -2022,7 +2426,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
checkResult += data.toString();
});
checkStream.on("close", (code) => {
checkStream.on("close", () => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,57 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import {
Client,
type ClientChannel,
type PseudoTtyOptions,
type ConnectConfig,
} from "ssh2";
import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
interface ConnectToHostData {
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;
forceKeyboardInteractive?: boolean;
};
initialPath?: string;
executeCommand?: string;
}
interface ResizeData {
cols: number;
rows: number;
}
interface TOTPResponseData {
code?: string;
}
interface WebSocketMessage {
type: string;
data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown;
code?: string;
[key: string]: unknown;
}
const authManager = AuthManager.getInstance();
const userCrypto = UserCrypto.getInstance();
@@ -22,47 +65,21 @@ const wss = new WebSocketServer({
const token = url.query.token as string;
if (!token) {
sshLogger.warn("WebSocket connection rejected: missing token", {
operation: "websocket_auth_reject",
reason: "missing_token",
ip: info.req.socket.remoteAddress,
});
return false;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload) {
sshLogger.warn("WebSocket connection rejected: invalid token", {
operation: "websocket_auth_reject",
reason: "invalid_token",
ip: info.req.socket.remoteAddress,
});
return false;
}
if (payload.pendingTOTP) {
sshLogger.warn(
"WebSocket connection rejected: TOTP verification pending",
{
operation: "websocket_auth_reject",
reason: "totp_pending",
userId: payload.userId,
ip: info.req.socket.remoteAddress,
},
);
return false;
}
const existingConnections = userConnections.get(payload.userId);
if (existingConnections && existingConnections.size >= 3) {
sshLogger.warn("WebSocket connection rejected: too many connections", {
operation: "websocket_auth_reject",
reason: "connection_limit",
userId: payload.userId,
currentConnections: existingConnections.size,
ip: info.req.socket.remoteAddress,
});
return false;
}
@@ -79,41 +96,23 @@ const wss = new WebSocketServer({
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
let userPayload: any;
try {
const url = parseUrl(req.url!, true);
const token = url.query.token as string;
if (!token) {
sshLogger.warn(
"WebSocket connection rejected: missing token in connection",
{
operation: "websocket_connection_reject",
reason: "missing_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload) {
sshLogger.warn(
"WebSocket connection rejected: invalid token in connection",
{
operation: "websocket_connection_reject",
reason: "invalid_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
userPayload = payload;
} catch (error) {
sshLogger.error(
"WebSocket JWT verification failed during connection",
@@ -129,11 +128,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
const dataKey = userCrypto.getUserDataKey(userId);
if (!dataKey) {
sshLogger.warn("WebSocket connection rejected: data locked", {
operation: "websocket_data_locked",
userId,
ip: req.socket.remoteAddress,
});
ws.send(
JSON.stringify({
type: "error",
@@ -154,6 +148,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let isKeyboardInteractive = false;
let keyboardInteractiveResponded = false;
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -170,11 +168,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.on("message", (msg: RawData) => {
const currentDataKey = userCrypto.getUserDataKey(userId);
if (!currentDataKey) {
sshLogger.warn("WebSocket message rejected: data access expired", {
operation: "websocket_message_rejected",
userId,
reason: "data_access_expired",
});
ws.send(
JSON.stringify({
type: "error",
@@ -186,9 +179,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
let parsed: any;
let parsed: WebSocketMessage;
try {
parsed = JSON.parse(msg.toString());
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
} catch (e) {
sshLogger.error("Invalid JSON received", e, {
operation: "websocket_message_invalid_json",
@@ -202,16 +195,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
const { type, data } = parsed;
switch (type) {
case "connectToHost":
if (data.hostConfig) {
data.hostConfig.userId = userId;
case "connectToHost": {
const connectData = data as ConnectToHostData;
if (connectData.hostConfig) {
connectData.hostConfig.userId = userId;
}
handleConnectToHost(data).catch((error) => {
handleConnectToHost(connectData).catch((error) => {
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
userId,
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip,
hostId: connectData.hostConfig?.id,
ip: connectData.hostConfig?.ip,
});
ws.send(
JSON.stringify({
@@ -223,40 +217,144 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
});
break;
}
case "resize":
handleResize(data);
case "resize": {
const resizeData = data as ResizeData;
handleResize(resizeData);
break;
}
case "disconnect":
cleanupSSH();
break;
case "input":
case "input": {
const inputData = data as string;
if (sshStream) {
if (data === "\t") {
sshStream.write(data);
} else if (data.startsWith("\x1b")) {
sshStream.write(data);
if (inputData === "\t") {
sshStream.write(inputData);
} else if (
typeof inputData === "string" &&
inputData.startsWith("\x1b")
) {
sshStream.write(inputData);
} else {
try {
sshStream.write(Buffer.from(data, "utf8"));
sshStream.write(Buffer.from(inputData, "utf8"));
} catch (error) {
sshLogger.error("Error writing input to SSH stream", error, {
operation: "ssh_input_encoding",
userId,
dataLength: data.length,
dataLength: inputData.length,
});
sshStream.write(Buffer.from(data, "latin1"));
sshStream.write(Buffer.from(inputData, "latin1"));
}
}
}
break;
}
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
case "totp_response": {
const totpData = data as TOTPResponseData;
if (keyboardInteractiveFinish && totpData?.code) {
const totpCode = totpData.code;
keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
} else {
sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error",
userId,
hasCallback: !!keyboardInteractiveFinish,
hasCode: !!totpData?.code,
});
ws.send(
JSON.stringify({
type: "error",
message: "TOTP authentication state lost. Please reconnect.",
}),
);
}
break;
}
case "password_response": {
const passwordData = data as TOTPResponseData;
if (keyboardInteractiveFinish && passwordData?.code) {
const password = passwordData.code;
keyboardInteractiveFinish([password]);
keyboardInteractiveFinish = null;
} else {
sshLogger.warn(
"Password response received but no callback available",
{
operation: "password_response_error",
userId,
hasCallback: !!keyboardInteractiveFinish,
hasCode: !!passwordData?.code,
},
);
ws.send(
JSON.stringify({
type: "error",
message: "Password authentication state lost. Please reconnect.",
}),
);
}
break;
}
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
rows: number;
hostConfig: ConnectToHostData["hostConfig"];
password?: string;
sshKey?: string;
keyPassword?: string;
};
if (credentialsData.password) {
credentialsData.hostConfig.password = credentialsData.password;
credentialsData.hostConfig.authType = "password";
(credentialsData.hostConfig as any).userProvidedPassword = true;
} else if (credentialsData.sshKey) {
credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
credentialsData.hostConfig.authType = "key";
}
cleanupSSH();
const reconnectData: ConnectToHostData = {
cols: credentialsData.cols,
rows: credentialsData.rows,
hostConfig: credentialsData.hostConfig,
};
handleConnectToHost(reconnectData).catch((error) => {
sshLogger.error("Failed to reconnect with credentials", error, {
operation: "ssh_reconnect_with_credentials",
userId,
hostId: credentialsData.hostConfig?.id,
ip: credentialsData.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect with provided credentials: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
}
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -266,26 +364,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
});
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;
};
initialPath?: string;
executeCommand?: string;
}) {
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand } = data;
const {
id,
ip,
@@ -356,6 +436,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, 60000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false;
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await SimpleDBOps.select(
@@ -375,12 +456,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authType: credential.auth_type || credential.authType,
password: credential.password as string | undefined,
key: (credential.private_key ||
credential.privateKey ||
credential.key) as string | undefined,
keyPassword: (credential.key_password || credential.keyPassword) as
| string
| undefined,
keyType: (credential.key_type || credential.keyType) as
| string
| undefined,
authType: (credential.auth_type || credential.authType) as
| string
| undefined,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
@@ -410,7 +498,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
sshConn!.shell(
if (!sshConn) {
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
{
operation: "ssh_shell",
hostId: id,
ip,
port,
username,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"SSH connection was closed before terminal could be created",
}),
);
return;
}
sshConn.shell(
{
rows: data.rows,
cols: data.cols,
@@ -497,12 +606,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
if (id && hostConfig.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(
and(
eq(sshData.id, id),
eq(sshData.userId, hostConfig.userId!),
),
),
"ssh_data",
hostConfig.userId!,
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${username}@${ip}:${port}`;
await axios.post(
"http://localhost:30006/activity/log",
{
type: "terminal",
hostId: id,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
},
},
);
} catch (error) {
sshLogger.warn("Failed to log terminal activity", {
operation: "activity_log_error",
userId: hostConfig.userId,
hostId: id,
error:
error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
},
);
});
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
if (
(authMethodNotAvailable && resolvedCredentials.authType === "none") ||
(resolvedCredentials.authType === "none" &&
err.message.includes("All configured authentication methods failed"))
) {
ws.send(
JSON.stringify({
type: "auth_method_not_available",
message:
"The server does not support keyboard-interactive authentication. Please provide credentials.",
}),
);
cleanupSSH(connectionTimeout);
return;
}
sshLogger.error("SSH connection error", err, {
operation: "ssh_connect",
hostId: id,
@@ -557,16 +730,115 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH(connectionTimeout);
});
sshConn.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
isKeyboardInteractive = true;
const promptTexts = prompts.map((p) => p.prompt);
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
operation: "ssh_keyboard_interactive_totp_duplicate",
hostId: id,
prompts: promptTexts,
});
return;
}
totpPromptSent = true;
keyboardInteractiveResponded = true;
keyboardInteractiveFinish = (totpResponses: string[]) => {
const totpCode = (totpResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
};
ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[totpPromptIndex].prompt,
}),
);
} else {
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (keyboardInteractiveResponded) {
return;
}
keyboardInteractiveResponded = true;
keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === passwordPromptIndex) {
return userInput;
}
return "";
});
finish(responses);
};
ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[passwordPromptIndex].prompt,
}),
);
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
},
);
const connectConfig: any = {
host: ip,
port,
username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
@@ -579,45 +851,72 @@ wss.on("connection", async (ws: WebSocket, req) => {
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"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",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
connectConfig.password = resolvedCredentials.password;
if (resolvedCredentials.authType === "none") {
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
"Password authentication requested but no password provided",
);
ws.send(
JSON.stringify({
type: "error",
message:
"Password authentication requested but no password provided",
}),
);
return;
}
if (!hostConfig.forceKeyboardInteractive) {
connectConfig.password = resolvedCredentials.password;
}
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
@@ -640,13 +939,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
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(
@@ -680,7 +972,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
function handleResize(data: ResizeData) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(
@@ -702,8 +994,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
sshLogger.error("Error closing stream: " + e.message);
} catch (e: unknown) {
sshLogger.error(
"Error closing stream: " +
(e instanceof Error ? e.message : "Unknown error"),
);
}
sshStream = null;
}
@@ -711,11 +1006,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
sshLogger.error("Error closing connection: " + e.message);
} catch (e: unknown) {
sshLogger.error(
"Error closing connection: " +
(e instanceof Error ? e.message : "Unknown error"),
);
}
sshConn = null;
}
totpPromptSent = false;
isKeyboardInteractive = false;
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
}
function setupPingInterval() {
@@ -723,8 +1026,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: any) {
sshLogger.error("SSH keepalive failed: " + e.message);
} catch (e: unknown) {
sshLogger.error(
"SSH keepalive failed: " +
(e instanceof Error ? e.message : "Unknown error"),
);
cleanupSSH();
}
}

View File

@@ -33,6 +33,10 @@ app.use(
"http://127.0.0.1:3000",
];
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
if (origin.startsWith("https://")) {
return callback(null, true);
}
@@ -41,10 +45,6 @@ app.use(
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
@@ -217,7 +217,7 @@ function cleanupTunnelResources(
if (verification?.timeout) clearTimeout(verification.timeout);
try {
verification?.conn.end();
} catch (e) {}
} catch {}
tunnelVerifications.delete(tunnelName);
}
@@ -282,7 +282,7 @@ function handleDisconnect(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch (e) {}
} catch {}
tunnelVerifications.delete(tunnelName);
}
@@ -511,16 +511,19 @@ async function connectSSHTunnel(
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
password: credential.password as string | undefined,
sshKey: (credential.private_key ||
credential.privateKey ||
credential.key) as string | undefined,
keyPassword: (credential.key_password || credential.keyPassword) as
| string
| undefined,
keyType: (credential.key_type || credential.keyType) as
| string
| undefined,
authMethod: (credential.auth_type || credential.authType) as string,
};
} else {
}
} else {
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source credentials from database", {
@@ -591,12 +594,17 @@ async function connectSSHTunnel(
if (credentials.length > 0) {
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
password: credential.password as string | undefined,
sshKey: (credential.private_key ||
credential.privateKey ||
credential.key) as string | undefined,
keyPassword: (credential.key_password || credential.keyPassword) as
| string
| undefined,
keyType: (credential.key_type || credential.keyType) as
| string
| undefined,
authMethod: (credential.auth_type || credential.authType) as string,
};
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
@@ -605,7 +613,6 @@ async function connectSSHTunnel(
credentialId: tunnelConfig.endpointCredentialId,
});
}
} else {
}
} catch (error) {
tunnelLogger.warn(
@@ -631,7 +638,7 @@ async function connectSSHTunnel(
try {
conn.end();
} catch (e) {}
} catch {}
activeTunnels.delete(tunnelName);
@@ -771,7 +778,7 @@ async function connectSSHTunnel(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch (e) {}
} catch {}
tunnelVerifications.delete(tunnelName);
}
@@ -822,13 +829,9 @@ async function connectSSHTunnel(
}
});
stream.stdout?.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
}
});
stream.stdout?.on("data", () => {});
stream.on("error", (err: Error) => {});
stream.on("error", () => {});
stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
@@ -888,42 +891,68 @@ async function connectSSHTunnel(
});
});
const connOptions: any = {
const connOptions: Record<string, unknown> = {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 15000,
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: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"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",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -1026,15 +1055,19 @@ async function killRemoteTunnelByMarker(
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
password: credential.password as string | undefined,
sshKey: (credential.private_key ||
credential.privateKey ||
credential.key) as string | undefined,
keyPassword: (credential.key_password || credential.keyPassword) as
| string
| undefined,
keyType: (credential.key_type || credential.keyType) as
| string
| undefined,
authMethod: (credential.auth_type || credential.authType) as string,
};
}
} else {
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
@@ -1046,7 +1079,7 @@ async function killRemoteTunnelByMarker(
}
const conn = new Client();
const connOptions: any = {
const connOptions: Record<string, unknown> = {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
@@ -1122,7 +1155,7 @@ async function killRemoteTunnelByMarker(
conn.on("ready", () => {
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
conn.exec(checkCmd, (err, stream) => {
conn.exec(checkCmd, (_err, stream) => {
let foundProcesses = false;
stream.on("data", (data) => {
@@ -1150,7 +1183,7 @@ async function killRemoteTunnelByMarker(
function executeNextKillCommand() {
if (commandIndex >= killCmds.length) {
conn.exec(checkCmd, (err, verifyStream) => {
conn.exec(checkCmd, (_err, verifyStream) => {
let stillRunning = false;
verifyStream.on("data", (data) => {
@@ -1183,19 +1216,14 @@ async function killRemoteTunnelByMarker(
tunnelLogger.warn(
`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
);
} else {
}
stream.on("close", (code) => {
stream.on("close", () => {
commandIndex++;
executeNextKillCommand();
});
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
}
});
stream.on("data", () => {});
stream.stderr.on("data", (data) => {
const output = data.toString().trim();
@@ -1381,7 +1409,11 @@ async function initializeAutoStartTunnels(): Promise<void> {
if (endpointHost) {
const tunnelConfig: TunnelConfig = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
name: `${host.name || `${host.username}@${host.ip}`}_${
tunnelConnection.sourcePort
}_${tunnelConnection.endpointHost}_${
tunnelConnection.endpointPort
}`,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
@@ -1423,14 +1455,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
isPinned: host.pin,
};
const hasSourcePassword = host.autostartPassword;
const hasSourceKey = host.autostartKey;
const hasEndpointPassword =
tunnelConnection.endpointPassword ||
endpointHost.autostartPassword;
const hasEndpointKey =
tunnelConnection.endpointKey || endpointHost.autostartKey;
autoStartTunnels.push(tunnelConfig);
} else {
tunnelLogger.error(
@@ -1453,10 +1477,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
});
}, 1000);
}
} catch (error: any) {
} catch (error) {
tunnelLogger.error(
"Failed to initialize auto-start tunnels:",
error.message,
error instanceof Error ? error.message : "Unknown error",
);
}
}

View File

@@ -73,7 +73,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
version = foundVersion;
break;
}
} catch (error) {
} catch {
continue;
}
}
@@ -102,6 +102,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js");
await import("./ssh/server-stats.js");
await import("./dashboard.js");
process.on("SIGINT", () => {
systemLogger.info(
@@ -126,7 +127,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
process.on("unhandledRejection", (reason) => {
systemLogger.error("Unhandled promise rejection", reason, {
operation: "error_handling",
});

View File

@@ -4,6 +4,11 @@ import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import { db } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
interface AuthenticationResult {
success: boolean;
@@ -18,16 +23,28 @@ interface AuthenticationResult {
interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
iat?: number;
exp?: number;
}
interface AuthenticatedRequest extends Request {
userId?: string;
pendingTOTP?: boolean;
dataKey?: Buffer;
}
interface RequestWithHeaders extends Request {
headers: Request["headers"] & {
"x-forwarded-proto"?: string;
};
}
class AuthManager {
private static instance: AuthManager;
private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = new Set();
private constructor() {
this.systemCrypto = SystemCrypto.getInstance();
@@ -36,6 +53,21 @@ class AuthManager {
this.userCrypto.setSessionExpiredCallback((userId: string) => {
this.invalidateUserTokens(userId);
});
setInterval(
() => {
this.cleanupExpiredSessions().catch((error) => {
databaseLogger.error(
"Failed to run periodic session cleanup",
error,
{
operation: "session_cleanup_periodic",
},
);
});
},
5 * 60 * 1000,
);
}
static getInstance(): AuthManager {
@@ -108,7 +140,6 @@ class AuthManager {
if (migrationResult.migrated) {
await saveMemoryDatabaseToFile();
} else {
}
} catch (error) {
databaseLogger.error("Lazy encryption migration failed", error, {
@@ -121,50 +152,323 @@ class AuthManager {
async generateJWTToken(
userId: string,
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
options: {
expiresIn?: string;
pendingTOTP?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
expiresIn = "30d";
} else {
expiresIn = "7d";
}
} else if (!expiresIn) {
expiresIn = "7d";
}
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
payload.pendingTOTP = true;
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "24h",
} as jwt.SignOptions);
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid();
payload.sessionId = sessionId;
const token = jwt.sign(payload, jwtSecret, {
expiresIn,
} as jwt.SignOptions);
const expirationMs = this.parseExpiresIn(expiresIn);
const now = new Date();
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
const createdAt = now.toISOString();
try {
await db.insert(sessions).values({
id: sessionId,
userId,
jwtToken: token,
deviceType: options.deviceType,
deviceInfo: options.deviceInfo,
createdAt,
expiresAt,
lastActiveAt: createdAt,
});
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session creation",
saveError,
{
operation: "session_create_db_save_failed",
sessionId,
},
);
}
} catch (error) {
databaseLogger.error("Failed to create session", error, {
operation: "session_create_failed",
userId,
sessionId,
});
}
return token;
}
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
}
private parseExpiresIn(expiresIn: string): number {
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000;
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 7 * 24 * 60 * 60 * 1000;
}
}
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
try {
if (this.invalidatedTokens.has(token)) {
return null;
}
const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
if (sessionRecords.length === 0) {
databaseLogger.warn("Session not found during JWT verification", {
operation: "jwt_verify_session_not_found",
sessionId: payload.sessionId,
userId: payload.userId,
});
return null;
}
} catch (dbError) {
databaseLogger.error(
"Failed to check session in database during JWT verification",
dbError,
{
operation: "jwt_verify_session_check_failed",
sessionId: payload.sessionId,
},
);
return null;
}
}
return payload;
} catch (error) {
databaseLogger.warn("JWT verification failed", {
operation: "jwt_verify_failed",
error: error instanceof Error ? error.message : "Unknown error",
errorName: error instanceof Error ? error.name : "Unknown",
});
return null;
}
}
invalidateJWTToken(token: string): void {
this.invalidatedTokens.add(token);
invalidateJWTToken(token: string): void {}
invalidateUserTokens(userId: string): void {}
async revokeSession(sessionId: string): Promise<boolean> {
try {
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session revocation",
saveError,
{
operation: "session_revoke_db_save_failed",
sessionId,
},
);
}
return true;
} catch (error) {
databaseLogger.error("Failed to delete session", error, {
operation: "session_delete_failed",
sessionId,
});
return false;
}
}
invalidateUserTokens(userId: string): void {
databaseLogger.info("User tokens invalidated due to data lock", {
operation: "user_tokens_invalidate",
userId,
});
async revokeAllUserSessions(
userId: string,
exceptSessionId?: string,
): Promise<number> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
).length;
if (exceptSessionId) {
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
sql`${sessions.id} != ${exceptSessionId}`,
),
);
} else {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after revoking all user sessions",
saveError,
{
operation: "user_sessions_revoke_db_save_failed",
userId,
},
);
}
return deletedCount;
} catch (error) {
databaseLogger.error("Failed to delete user sessions", error, {
operation: "user_sessions_delete_failed",
userId,
});
return 0;
}
}
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
async cleanupExpiredSessions(): Promise<number> {
try {
const expiredSessions = await db
.select()
.from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
const expiredCount = expiredSessions.length;
if (expiredCount === 0) {
return 0;
}
await db
.delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after cleaning up expired sessions",
saveError,
{
operation: "sessions_cleanup_db_save_failed",
},
);
}
const affectedUsers = new Set(expiredSessions.map((s) => s.userId));
for (const userId of affectedUsers) {
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
}
}
return expiredCount;
} catch (error) {
databaseLogger.error("Failed to cleanup expired sessions", error, {
operation: "sessions_cleanup_failed",
});
return 0;
}
}
async getAllSessions(): Promise<any[]> {
try {
const allSessions = await db.select().from(sessions);
return allSessions;
} catch (error) {
databaseLogger.error("Failed to get all sessions", error, {
operation: "sessions_get_all_failed",
});
return [];
}
}
async getUserSessions(userId: string): Promise<any[]> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
return userSessions;
} catch (error) {
databaseLogger.error("Failed to get user sessions", error, {
operation: "sessions_get_user_failed",
userId,
});
return [];
}
}
getSecureCookieOptions(
req: RequestWithHeaders,
maxAge: number = 7 * 24 * 60 * 60 * 1000,
) {
return {
httpOnly: false,
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
@@ -176,10 +480,11 @@ class AuthManager {
createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
let token = req.cookies?.jwt;
const authReq = req as AuthenticatedRequest;
let token = authReq.cookies?.jwt;
if (!token) {
const authHeader = req.headers["authorization"];
const authHeader = authReq.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.split(" ")[1];
}
@@ -195,40 +500,142 @@ class AuthManager {
return res.status(401).json({ error: "Invalid token" });
}
(req as any).userId = payload.userId;
(req as any).pendingTOTP = payload.pendingTOTP;
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
if (sessionRecords.length === 0) {
databaseLogger.warn("Session not found in middleware", {
operation: "middleware_session_not_found",
sessionId: payload.sessionId,
userId: payload.userId,
});
return res.status(401).json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
}
const session = sessionRecords[0];
const sessionExpiryTime = new Date(session.expiresAt).getTime();
const currentTime = Date.now();
const isExpired = sessionExpiryTime < currentTime;
if (isExpired) {
databaseLogger.warn("Session has expired", {
operation: "session_expired",
sessionId: payload.sessionId,
expiresAt: session.expiresAt,
expiryTime: sessionExpiryTime,
currentTime: currentTime,
difference: currentTime - sessionExpiryTime,
});
db.delete(sessions)
.where(eq(sessions.id, payload.sessionId))
.then(async () => {
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, payload.userId));
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(payload.userId);
}
} catch (cleanupError) {
databaseLogger.error(
"Failed to cleanup after expired session",
cleanupError,
{
operation: "expired_session_cleanup_failed",
sessionId: payload.sessionId,
},
);
}
})
.catch((error) => {
databaseLogger.error(
"Failed to delete expired session",
error,
{
operation: "expired_session_delete_failed",
sessionId: payload.sessionId,
},
);
});
return res.status(401).json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
}
db.update(sessions)
.set({ lastActiveAt: new Date().toISOString() })
.where(eq(sessions.id, payload.sessionId))
.then(() => {})
.catch((error) => {
databaseLogger.warn("Failed to update session lastActiveAt", {
operation: "session_update_last_active",
sessionId: payload.sessionId,
error: error instanceof Error ? error.message : "Unknown error",
});
});
} catch (error) {
databaseLogger.error("Session check failed in middleware", error, {
operation: "middleware_session_check_failed",
sessionId: payload.sessionId,
});
return res.status(500).json({ error: "Session check failed" });
}
}
authReq.userId = payload.userId;
authReq.pendingTOTP = payload.pendingTOTP;
next();
};
}
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = (req as any).userId;
const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const dataKey = this.userCrypto.getUserDataKey(userId);
if (!dataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
(req as any).dataKey = dataKey;
authReq.dataKey = dataKey || undefined;
next();
};
}
createAdminMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing Authorization header" });
let token = req.cookies?.jwt;
if (!token) {
const authHeader = req.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.split(" ")[1];
}
}
if (!token) {
return res.status(401).json({ error: "Missing authentication token" });
}
const token = authHeader.split(" ")[1];
const payload = await this.verifyJWTToken(token);
if (!payload) {
@@ -257,8 +664,9 @@ class AuthManager {
return res.status(403).json({ error: "Admin access required" });
}
(req as any).userId = payload.userId;
(req as any).pendingTOTP = payload.pendingTOTP;
const authReq = req as AuthenticatedRequest;
authReq.userId = payload.userId;
authReq.pendingTOTP = payload.pendingTOTP;
next();
} catch (error) {
databaseLogger.error("Failed to verify admin privileges", error, {
@@ -272,8 +680,47 @@ class AuthManager {
};
}
logoutUser(userId: string): void {
this.userCrypto.logoutUser(userId);
async logoutUser(userId: string, sessionId?: string): Promise<void> {
if (sessionId) {
try {
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after logout",
saveError,
{
operation: "logout_db_save_failed",
userId,
sessionId,
},
);
}
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
} else {
}
} catch (error) {
databaseLogger.error("Failed to delete session on logout", error, {
operation: "session_delete_logout_failed",
userId,
sessionId,
});
}
} else {
this.userCrypto.logoutUser(userId);
}
}
getUserDataKey(userId: string): Buffer | null {

View File

@@ -1,7 +1,6 @@
import { execSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
import { systemLogger } from "./logger.js";
export class AutoSSLSetup {
@@ -102,7 +101,7 @@ export class AutoSSLSetup {
try {
try {
execSync("openssl version", { stdio: "pipe" });
} catch (error) {
} catch {
throw new Error(
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
);

View File

@@ -3,6 +3,19 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js";
import { UserCrypto } from "./user-crypto.js";
import { databaseLogger } from "./logger.js";
interface DatabaseInstance {
prepare: (sql: string) => {
all: (param?: unknown) => DatabaseRecord[];
get: (param?: unknown) => DatabaseRecord;
run: (...params: unknown[]) => unknown;
};
}
interface DatabaseRecord {
id: number | string;
[key: string]: unknown;
}
class DataCrypto {
private static userCrypto: UserCrypto;
@@ -10,13 +23,13 @@ class DataCrypto {
this.userCrypto = UserCrypto.getInstance();
}
static encryptRecord(
static encryptRecord<T extends Record<string, unknown>>(
tableName: string,
record: any,
record: T,
userId: string,
userDataKey: Buffer,
): any {
const encryptedRecord = { ...record };
): T {
const encryptedRecord: Record<string, unknown> = { ...record };
const recordId = record.id || "temp-" + Date.now();
for (const [fieldName, value] of Object.entries(record)) {
@@ -24,24 +37,24 @@ class DataCrypto {
encryptedRecord[fieldName] = FieldCrypto.encryptField(
value as string,
userDataKey,
recordId,
recordId as string,
fieldName,
);
}
}
return encryptedRecord;
return encryptedRecord as T;
}
static decryptRecord(
static decryptRecord<T extends Record<string, unknown>>(
tableName: string,
record: any,
record: T,
userId: string,
userDataKey: Buffer,
): any {
): T {
if (!record) return record;
const decryptedRecord = { ...record };
const decryptedRecord: Record<string, unknown> = { ...record };
const recordId = record.id;
for (const [fieldName, value] of Object.entries(record)) {
@@ -49,21 +62,21 @@ class DataCrypto {
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
value as string,
userDataKey,
recordId,
recordId as string,
fieldName,
);
}
}
return decryptedRecord;
return decryptedRecord as T;
}
static decryptRecords(
static decryptRecords<T extends Record<string, unknown>>(
tableName: string,
records: any[],
records: T[],
userId: string,
userDataKey: Buffer,
): any[] {
): T[] {
if (!Array.isArray(records)) return records;
return records.map((record) =>
this.decryptRecord(tableName, record, userId, userDataKey),
@@ -73,7 +86,7 @@ class DataCrypto {
static async migrateUserSensitiveFields(
userId: string,
userDataKey: Buffer,
db: any,
db: DatabaseInstance,
): Promise<{
migrated: boolean;
migratedTables: string[];
@@ -84,7 +97,7 @@ class DataCrypto {
let migratedFieldsCount = 0;
try {
const { needsMigration, plaintextFields } =
const { needsMigration } =
await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
@@ -97,7 +110,7 @@ class DataCrypto {
const sshDataRecords = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
.all(userId) as DatabaseRecord[];
for (const record of sshDataRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
@@ -112,13 +125,17 @@ class DataCrypto {
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.key_password || updatedRecord.keyPassword || null,
updatedRecord.keyType || null,
updatedRecord.autostartPassword || null,
updatedRecord.autostartKey || null,
updatedRecord.autostartKeyPassword || null,
record.id,
);
@@ -132,7 +149,7 @@ class DataCrypto {
const sshCredentialsRecords = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
.all(userId) as DatabaseRecord[];
for (const record of sshCredentialsRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
@@ -147,15 +164,16 @@ class DataCrypto {
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.private_key || null,
updatedRecord.public_key || null,
updatedRecord.key_password || updatedRecord.keyPassword || null,
updatedRecord.private_key || updatedRecord.privateKey || null,
updatedRecord.public_key || updatedRecord.publicKey || null,
updatedRecord.keyType || null,
record.id,
);
@@ -169,7 +187,7 @@ class DataCrypto {
const userRecord = db
.prepare("SELECT * FROM users WHERE id = ?")
.get(userId);
.get(userId) as DatabaseRecord | undefined;
if (userRecord) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("users");
@@ -184,12 +202,18 @@ class DataCrypto {
if (needsUpdate) {
const updateQuery = `
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.totp_secret || null,
updatedRecord.totp_backup_codes || null,
updatedRecord.totp_secret || updatedRecord.totpSecret || null,
updatedRecord.totp_backup_codes ||
updatedRecord.totpBackupCodes ||
null,
updatedRecord.client_secret || updatedRecord.clientSecret || null,
updatedRecord.oidc_identifier ||
updatedRecord.oidcIdentifier ||
null,
userId,
);
@@ -220,7 +244,7 @@ class DataCrypto {
static async reencryptUserDataAfterPasswordReset(
userId: string,
newUserDataKey: Buffer,
db: any,
db: DatabaseInstance,
): Promise<{
success: boolean;
reencryptedTables: string[];
@@ -236,24 +260,44 @@ class DataCrypto {
try {
const tablesToReencrypt = [
{ table: "ssh_data", fields: ["password", "key", "key_password"] },
{
table: "ssh_data",
fields: [
"password",
"key",
"key_password",
"keyPassword",
"keyType",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
],
},
{
table: "ssh_credentials",
fields: [
"password",
"private_key",
"privateKey",
"key_password",
"keyPassword",
"key",
"public_key",
"publicKey",
"keyType",
],
},
{
table: "users",
fields: [
"client_secret",
"clientSecret",
"totp_secret",
"totpSecret",
"totp_backup_codes",
"totpBackupCodes",
"oidc_identifier",
"oidcIdentifier",
],
},
];
@@ -262,17 +306,21 @@ class DataCrypto {
try {
const records = db
.prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
.all(userId);
.all(userId) as DatabaseRecord[];
for (const record of records) {
const recordId = record.id.toString();
const updatedRecord: DatabaseRecord = { ...record };
let needsUpdate = false;
const updatedRecord = { ...record };
for (const fieldName of fields) {
const fieldValue = record[fieldName];
if (fieldValue && fieldValue.trim() !== "") {
if (
fieldValue &&
typeof fieldValue === "string" &&
fieldValue.trim() !== ""
) {
try {
const reencryptedValue = FieldCrypto.encryptField(
fieldValue,
@@ -345,18 +393,6 @@ class DataCrypto {
result.success = result.errors.length === 0;
databaseLogger.info(
"User data re-encryption completed after password reset",
{
operation: "password_reset_reencrypt_completed",
userId,
success: result.success,
reencryptedTables: result.reencryptedTables,
reencryptedFieldsCount: result.reencryptedFieldsCount,
errorsCount: result.errors.length,
},
);
return result;
} catch (error) {
databaseLogger.error(
@@ -384,29 +420,29 @@ class DataCrypto {
return userDataKey;
}
static encryptRecordForUser(
static encryptRecordForUser<T extends Record<string, unknown>>(
tableName: string,
record: any,
record: T,
userId: string,
): any {
): T {
const userDataKey = this.validateUserAccess(userId);
return this.encryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordForUser(
static decryptRecordForUser<T extends Record<string, unknown>>(
tableName: string,
record: any,
record: T,
userId: string,
): any {
): T {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordsForUser(
static decryptRecordsForUser<T extends Record<string, unknown>>(
tableName: string,
records: any[],
records: T[],
userId: string,
): any[] {
): T[] {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecords(tableName, records, userId, userDataKey);
}
@@ -435,7 +471,7 @@ class DataCrypto {
);
return decrypted === testData;
} catch (error) {
} catch {
return false;
}
}

View File

@@ -30,7 +30,11 @@ class DatabaseFileEncryption {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const cipher = crypto.createCipheriv(
this.ALGORITHM,
key,
iv,
) as crypto.CipherGCM;
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
const tag = cipher.getAuthTag();
@@ -78,7 +82,11 @@ class DatabaseFileEncryption {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const cipher = crypto.createCipheriv(
this.ALGORITHM,
key,
iv,
) as crypto.CipherGCM;
const encrypted = Buffer.concat([
cipher.update(sourceData),
cipher.final(),
@@ -163,7 +171,7 @@ class DatabaseFileEncryption {
metadata.algorithm,
key,
Buffer.from(metadata.iv, "hex"),
) as any;
) as crypto.DecipherGCM;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decryptedBuffer = Buffer.concat([
@@ -233,7 +241,7 @@ class DatabaseFileEncryption {
metadata.algorithm,
key,
Buffer.from(metadata.iv, "hex"),
) as any;
) as crypto.DecipherGCM;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decrypted = Buffer.concat([
@@ -301,7 +309,6 @@ class DatabaseFileEncryption {
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileStats = fs.statSync(encryptedPath);
const currentFingerprint = "termix-v1-file";
return {
version: metadata.version,

View File

@@ -55,7 +55,6 @@ export class DatabaseMigration {
if (hasEncryptedDb && hasUnencryptedDb) {
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
const encryptedSize = fs.statSync(this.encryptedDbPath).size;
if (unencryptedSize === 0) {
needsMigration = false;
@@ -63,10 +62,6 @@ export class DatabaseMigration {
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
try {
fs.unlinkSync(this.unencryptedDbPath);
databaseLogger.info("Removed empty unencrypted database file", {
operation: "migration_cleanup_empty",
path: this.unencryptedDbPath,
});
} catch (error) {
databaseLogger.warn("Failed to remove empty unencrypted database", {
operation: "migration_cleanup_empty_failed",
@@ -168,9 +163,6 @@ export class DatabaseMigration {
return false;
}
let totalOriginalRows = 0;
let totalMemoryRows = 0;
for (const table of originalTables) {
const originalCount = originalDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
@@ -179,9 +171,6 @@ export class DatabaseMigration {
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
totalOriginalRows += originalCount.count;
totalMemoryRows += memoryCount.count;
if (originalCount.count !== memoryCount.count) {
databaseLogger.error(
"Row count mismatch for table during migration verification",
@@ -241,7 +230,9 @@ export class DatabaseMigration {
memoryDb.exec("PRAGMA foreign_keys = OFF");
for (const table of tables) {
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
const rows = originalDb
.prepare(`SELECT * FROM ${table.name}`)
.all() as Record<string, unknown>[];
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
@@ -251,7 +242,7 @@ export class DatabaseMigration {
);
const insertTransaction = memoryDb.transaction(
(dataRows: any[]) => {
(dataRows: Record<string, unknown>[]) => {
for (const row of dataRows) {
const values = columns.map((col) => row[col]);
insertStmt.run(values);

View File

@@ -71,11 +71,6 @@ export class DatabaseSaveTrigger {
this.pendingSave = true;
try {
databaseLogger.info("Force saving database", {
operation: "db_save_trigger_force_start",
reason,
});
await this.saveFunction();
} catch (error) {
databaseLogger.error("Database force save failed", error, {
@@ -110,9 +105,5 @@ export class DatabaseSaveTrigger {
this.pendingSave = false;
this.isInitialized = false;
this.saveFunction = null;
databaseLogger.info("Database save trigger cleaned up", {
operation: "db_save_trigger_cleanup",
});
}
}

View File

@@ -17,18 +17,36 @@ class FieldCrypto {
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"password_hash",
"passwordHash",
"client_secret",
"clientSecret",
"totp_secret",
"totpSecret",
"totp_backup_codes",
"totpBackupCodes",
"oidc_identifier",
"oidcIdentifier",
]),
ssh_data: new Set([
"password",
"key",
"key_password",
"keyPassword",
"keyType",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
]),
ssh_data: new Set(["password", "key", "key_password"]),
ssh_credentials: new Set([
"password",
"private_key",
"privateKey",
"key_password",
"keyPassword",
"key",
"public_key",
"publicKey",
"keyType",
]),
};
@@ -47,7 +65,11 @@ class FieldCrypto {
);
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
const cipher = crypto.createCipheriv(
this.ALGORITHM,
fieldKey,
iv,
) as crypto.CipherGCM;
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
@@ -89,7 +111,7 @@ class FieldCrypto {
this.ALGORITHM,
fieldKey,
Buffer.from(encrypted.iv, "hex"),
) as any;
) as crypto.DecipherGCM;
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
let decrypted = decipher.update(encrypted.data, "hex", "utf8");

View File

@@ -1,6 +1,14 @@
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
interface DatabaseInstance {
prepare: (sql: string) => {
all: (param?: unknown) => unknown[];
get: (param?: unknown) => unknown;
run: (...params: unknown[]) => unknown;
};
}
export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
key_password: "keyPassword",
@@ -39,7 +47,7 @@ export class LazyFieldEncryption {
return false;
}
return true;
} catch (jsonError) {
} catch {
return true;
}
}
@@ -74,7 +82,7 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return decrypted;
} catch (legacyError) {}
} catch {}
}
const sensitiveFields = [
@@ -145,7 +153,7 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: false,
};
} catch (error) {
} catch {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
@@ -166,7 +174,7 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: true,
};
} catch (legacyError) {}
} catch {}
}
return {
encrypted: fieldValue,
@@ -178,12 +186,12 @@ export class LazyFieldEncryption {
}
static migrateRecordSensitiveFields(
record: any,
record: Record<string, unknown>,
sensitiveFields: string[],
userKEK: Buffer,
recordId: string,
): {
updatedRecord: any;
updatedRecord: Record<string, unknown>;
migratedFields: string[];
needsUpdate: boolean;
} {
@@ -198,7 +206,7 @@ export class LazyFieldEncryption {
try {
const { encrypted, wasPlaintext, wasLegacyEncryption } =
this.migrateFieldToEncrypted(
fieldValue,
fieldValue as string,
userKEK,
recordId,
fieldName,
@@ -253,7 +261,7 @@ export class LazyFieldEncryption {
try {
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
return false;
} catch (error) {
} catch {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
@@ -264,7 +272,7 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return true;
} catch (legacyError) {
} catch {
return false;
}
}
@@ -275,7 +283,7 @@ export class LazyFieldEncryption {
static async checkUserNeedsMigration(
userId: string,
userKEK: Buffer,
db: any,
db: DatabaseInstance,
): Promise<{
needsMigration: boolean;
plaintextFields: Array<{
@@ -294,7 +302,9 @@ export class LazyFieldEncryption {
try {
const sshHosts = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
.all(userId) as Array<
Record<string, unknown> & { id: string | number }
>;
for (const host of sshHosts) {
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
const hostPlaintextFields: string[] = [];
@@ -303,7 +313,7 @@ export class LazyFieldEncryption {
if (
host[field] &&
this.fieldNeedsMigration(
host[field],
host[field] as string,
userKEK,
host.id.toString(),
field,
@@ -325,7 +335,9 @@ export class LazyFieldEncryption {
const sshCredentials = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
.all(userId) as Array<
Record<string, unknown> & { id: string | number }
>;
for (const credential of sshCredentials) {
const sensitiveFields =
this.getSensitiveFieldsForTable("ssh_credentials");
@@ -335,7 +347,7 @@ export class LazyFieldEncryption {
if (
credential[field] &&
this.fieldNeedsMigration(
credential[field],
credential[field] as string,
userKEK,
credential.id.toString(),
field,

View File

@@ -11,7 +11,7 @@ export interface LogContext {
sessionId?: string;
requestId?: string;
duration?: number;
[key: string]: any;
[key: string]: unknown;
}
const SENSITIVE_FIELDS = [
@@ -253,5 +253,6 @@ 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 dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
export const logger = systemLogger;

View File

@@ -2,10 +2,10 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = "users" | "ssh_data" | "ssh_credentials";
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
class SimpleDBOps {
static async insert<T extends Record<string, any>>(
static async insert<T extends Record<string, unknown>>(
table: SQLiteTable<any>,
tableName: TableName,
data: T,
@@ -44,8 +44,8 @@ class SimpleDBOps {
return decryptedResult as T;
}
static async select<T extends Record<string, any>>(
query: any,
static async select<T extends Record<string, unknown>>(
query: unknown,
tableName: TableName,
userId: string,
): Promise<T[]> {
@@ -56,9 +56,9 @@ class SimpleDBOps {
const results = await query;
const decryptedResults = DataCrypto.decryptRecords(
const decryptedResults = DataCrypto.decryptRecords<T>(
tableName,
results,
results as T[],
userId,
userDataKey,
);
@@ -66,8 +66,8 @@ class SimpleDBOps {
return decryptedResults;
}
static async selectOne<T extends Record<string, any>>(
query: any,
static async selectOne<T extends Record<string, unknown>>(
query: unknown,
tableName: TableName,
userId: string,
): Promise<T | undefined> {
@@ -79,9 +79,9 @@ class SimpleDBOps {
const result = await query;
if (!result) return undefined;
const decryptedResult = DataCrypto.decryptRecord(
const decryptedResult = DataCrypto.decryptRecord<T>(
tableName,
result,
result as T,
userId,
userDataKey,
);
@@ -89,10 +89,10 @@ class SimpleDBOps {
return decryptedResult;
}
static async update<T extends Record<string, any>>(
static async update<T extends Record<string, unknown>>(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
where: unknown,
data: Partial<T>,
userId: string,
): Promise<T[]> {
@@ -108,7 +108,7 @@ class SimpleDBOps {
const result = await getDb()
.update(table)
.set(encryptedData)
.where(where)
.where(where as any)
.returning();
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
@@ -126,10 +126,12 @@ class SimpleDBOps {
static async delete(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
userId: string,
): Promise<any[]> {
const result = await getDb().delete(table).where(where).returning();
where: unknown,
): Promise<unknown[]> {
const result = await getDb()
.delete(table)
.where(where as any)
.returning();
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
@@ -144,13 +146,10 @@ class SimpleDBOps {
return DataCrypto.getUserDataKey(userId) !== null;
}
static async selectEncrypted(
query: any,
tableName: TableName,
): Promise<any[]> {
static async selectEncrypted(query: unknown): Promise<unknown[]> {
const results = await query;
return results;
return results as unknown[];
}
}

View File

@@ -49,7 +49,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
}
return "ssh-rsa";
} catch (error) {
} catch {
return "ssh-rsa";
}
}
@@ -84,7 +84,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {}
} catch {}
if (content.length < 800) {
return "ssh-ed25519";
@@ -140,7 +140,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {}
} catch {}
if (content.length < 400) {
return "ssh-ed25519";
@@ -236,13 +236,13 @@ export function parseSSHKey(
} else {
publicKey = "";
}
} catch (error) {
} catch {
publicKey = "";
}
useSSH2 = true;
}
} catch (error) {}
} catch {}
}
if (!useSSH2) {
@@ -268,7 +268,7 @@ export function parseSSHKey(
success: true,
};
}
} catch (fallbackError) {}
} catch {}
return {
privateKey: privateKeyData,
@@ -310,7 +310,7 @@ export function detectKeyType(privateKeyData: string): string {
return "unknown";
}
return parsedKey.type || "unknown";
} catch (error) {
} catch {
return "unknown";
}
}

View File

@@ -35,10 +35,33 @@ class SystemCrypto {
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
this.jwtSecret = jwtMatch[1];
process.env.JWT_SECRET = jwtMatch[1];
databaseLogger.success("JWT secret loaded from .env file", {
operation: "jwt_init_from_file_success",
secretLength: jwtMatch[1].length,
secretPrefix: jwtMatch[1].substring(0, 8) + "...",
});
return;
} else {
databaseLogger.warn(
"JWT_SECRET in .env file is invalid or too short",
{
operation: "jwt_init_invalid_secret",
hasMatch: !!jwtMatch,
secretLength: jwtMatch?.[1]?.length || 0,
},
);
}
} catch {}
} catch (fileError) {
databaseLogger.warn("Failed to read .env file for JWT secret", {
operation: "jwt_init_file_read_failed",
error:
fileError instanceof Error ? fileError.message : "Unknown error",
});
}
databaseLogger.warn("Generating new JWT secret", {
operation: "jwt_generating_new_secret",
});
await this.generateAndGuideUser();
} catch (error) {
databaseLogger.error("Failed to initialize JWT secret", error, {

View File

@@ -0,0 +1,239 @@
import type { Request } from "express";
export type DeviceType = "web" | "desktop" | "mobile";
export interface DeviceInfo {
type: DeviceType;
browser: string;
version: string;
os: string;
deviceInfo: string;
}
export function detectPlatform(req: Request): DeviceType {
const userAgent = req.headers["user-agent"] || "";
const electronHeader = req.headers["x-electron-app"];
if (electronHeader === "true" || userAgent.includes("Termix-Desktop")) {
return "desktop";
}
if (userAgent.includes("Termix-Mobile")) {
return "mobile";
}
if (userAgent.includes("Android")) {
return "mobile";
}
return "web";
}
export function parseUserAgent(req: Request): DeviceInfo {
const userAgent = req.headers["user-agent"] || "Unknown";
const platform = detectPlatform(req);
if (platform === "desktop") {
return parseElectronUserAgent(userAgent);
}
if (platform === "mobile") {
return parseMobileUserAgent(userAgent);
}
return parseWebUserAgent(userAgent);
}
function parseElectronUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
const termixMatch = userAgent.match(/Termix-Desktop\/([\d.]+)\s*\(([^;)]+)/);
if (termixMatch) {
version = termixMatch[1];
os = termixMatch[2].trim();
} else {
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("macOS")) {
os = "macOS";
} else if (userAgent.includes("Linux")) {
os = "Linux";
}
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
if (electronMatch) {
version = electronMatch[1];
}
}
return {
type: "desktop",
browser: "Termix Desktop",
version,
os,
deviceInfo: `Termix Desktop on ${os}`,
};
}
function parseMobileUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
if (termixPlatformMatch) {
const platform = termixPlatformMatch[1];
if (platform.toLowerCase() === "android") {
const androidMatch = userAgent.match(/Android ([\d.]+)/);
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
} else if (platform.toLowerCase() === "ios") {
const iosMatch = userAgent.match(/OS ([\d_]+)/);
if (iosMatch) {
const iosVersion = iosMatch[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
} else {
if (userAgent.includes("Android")) {
const androidMatch = userAgent.match(/Android ([\d.]+)/);
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const iosMatch = userAgent.match(/OS ([\d_]+)/);
if (iosMatch) {
const iosVersion = iosMatch[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
}
const versionMatch = userAgent.match(
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
);
if (versionMatch) {
version = versionMatch[1];
}
return {
type: "mobile",
browser: "Termix Mobile",
version,
os,
deviceInfo: `Termix Mobile on ${os}`,
};
}
function parseWebUserAgent(userAgent: string): DeviceInfo {
let browser = "Unknown Browser";
let version = "Unknown";
let os = "Unknown OS";
if (userAgent.includes("Edg/")) {
const match = userAgent.match(/Edg\/([\d.]+)/);
browser = "Edge";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) {
const match = userAgent.match(/Chrome\/([\d.]+)/);
browser = "Chrome";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Firefox/")) {
const match = userAgent.match(/Firefox\/([\d.]+)/);
browser = "Firefox";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) {
const match = userAgent.match(/Version\/([\d.]+)/);
browser = "Safari";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) {
const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/);
browser = "Opera";
version = match ? match[1] : "Unknown";
}
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Android")) {
const match = userAgent.match(/Android ([\d.]+)/);
os = match ? `Android ${match[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const match = userAgent.match(/OS ([\d_]+)/);
if (match) {
const iosVersion = match[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("Linux")) {
os = "Linux";
}
if (version !== "Unknown") {
const versionParts = version.split(".");
version = versionParts.slice(0, 2).join(".");
}
return {
type: "web",
browser,
version,
os,
deviceInfo: `${browser} ${version} on ${os}`,
};
}
function parseWindowsVersion(userAgent: string): string {
if (userAgent.includes("Windows NT 10.0")) {
return "Windows 10/11";
} else if (userAgent.includes("Windows NT 6.3")) {
return "Windows 8.1";
} else if (userAgent.includes("Windows NT 6.2")) {
return "Windows 8";
} else if (userAgent.includes("Windows NT 6.1")) {
return "Windows 7";
} else if (userAgent.includes("Windows NT 6.0")) {
return "Windows Vista";
} else if (
userAgent.includes("Windows NT 5.1") ||
userAgent.includes("Windows NT 5.2")
) {
return "Windows XP";
}
return "Windows";
}
function parseMacVersion(userAgent: string): string {
const match = userAgent.match(/Mac OS X ([\d_]+)/);
if (match) {
const version = match[1].replace(/_/g, ".");
const parts = version.split(".");
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
if (major === 10) {
if (minor >= 15) return `macOS ${major}.${minor}`;
if (minor === 14) return "macOS Mojave";
if (minor === 13) return "macOS High Sierra";
if (minor === 12) return "macOS Sierra";
} else if (major >= 11) {
return `macOS ${major}`;
}
return `macOS ${version}`;
}
return "macOS";
}

View File

@@ -163,9 +163,10 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
if (!kekSalt || !encryptedDEK) {
await this.setupOIDCUserEncryption(userId);
return true;
}
@@ -195,7 +196,7 @@ class UserCrypto {
DEK.fill(0);
return true;
} catch (error) {
} catch {
await this.setupOIDCUserEncryption(userId);
return true;
}
@@ -276,21 +277,6 @@ class UserCrypto {
oldKEK.fill(0);
newKEK.fill(0);
const dekCopy = Buffer.from(DEK);
const now = Date.now();
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: dekCopy,
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
return true;
@@ -363,7 +349,7 @@ class UserCrypto {
DEK.fill(0);
return true;
} catch (error) {
} catch {
return false;
}
}
@@ -482,7 +468,7 @@ class UserCrypto {
}
return JSON.parse(result[0].value);
} catch (error) {
} catch {
return null;
}
}
@@ -522,7 +508,7 @@ class UserCrypto {
}
return JSON.parse(result[0].value);
} catch (error) {
} catch {
return null;
}
}

View File

@@ -18,14 +18,14 @@ interface UserExportData {
userId: string;
username: string;
userData: {
sshHosts: any[];
sshCredentials: any[];
sshHosts: unknown[];
sshCredentials: unknown[];
fileManagerData: {
recent: any[];
pinned: any[];
shortcuts: any[];
recent: unknown[];
pinned: unknown[];
shortcuts: unknown[];
};
dismissedAlerts: any[];
dismissedAlerts: unknown[];
};
metadata: {
totalRecords: number;
@@ -83,7 +83,7 @@ class UserDataExport {
)
: sshHosts;
let sshCredentialsData: any[] = [];
let sshCredentialsData: unknown[] = [];
if (includeCredentials) {
const credentials = await getDb()
.select()
@@ -185,7 +185,10 @@ class UserDataExport {
return JSON.stringify(exportData, null, pretty ? 2 : 0);
}
static validateExportData(data: any): { valid: boolean; errors: string[] } {
static validateExportData(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!data || typeof data !== "object") {
@@ -193,23 +196,26 @@ class UserDataExport {
return { valid: false, errors };
}
if (!data.version) {
const dataObj = data as Record<string, unknown>;
if (!dataObj.version) {
errors.push("Missing version field");
}
if (!data.userId) {
if (!dataObj.userId) {
errors.push("Missing userId field");
}
if (!data.userData || typeof data.userData !== "object") {
if (!dataObj.userData || typeof dataObj.userData !== "object") {
errors.push("Missing or invalid userData field");
}
if (!data.metadata || typeof data.metadata !== "object") {
if (!dataObj.metadata || typeof dataObj.metadata !== "object") {
errors.push("Missing or invalid metadata field");
}
if (data.userData) {
if (dataObj.userData) {
const userData = dataObj.userData as Record<string, unknown>;
const requiredFields = [
"sshHosts",
"sshCredentials",
@@ -218,23 +224,24 @@ class UserDataExport {
];
for (const field of requiredFields) {
if (
!Array.isArray(data.userData[field]) &&
!(
field === "fileManagerData" &&
typeof data.userData[field] === "object"
)
!Array.isArray(userData[field]) &&
!(field === "fileManagerData" && typeof userData[field] === "object")
) {
errors.push(`Missing or invalid userData.${field} field`);
}
}
if (
data.userData.fileManagerData &&
typeof data.userData.fileManagerData === "object"
userData.fileManagerData &&
typeof userData.fileManagerData === "object"
) {
const fileManagerData = userData.fileManagerData as Record<
string,
unknown
>;
const fmFields = ["recent", "pinned", "shortcuts"];
for (const field of fmFields) {
if (!Array.isArray(data.userData.fileManagerData[field])) {
if (!Array.isArray(fileManagerData[field])) {
errors.push(
`Missing or invalid userData.fileManagerData.${field} field`,
);

View File

@@ -12,7 +12,6 @@ import { eq, and } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { UserDataExport, type UserExportData } from "./user-data-export.js";
import { databaseLogger } from "./logger.js";
import { nanoid } from "nanoid";
interface ImportOptions {
replaceExisting?: boolean;
@@ -90,7 +89,7 @@ class UserDataImport {
) {
const importStats = await this.importSshHosts(
targetUserId,
exportData.userData.sshHosts,
exportData.userData.sshHosts as Record<string, unknown>[],
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshHostsImported = importStats.imported;
@@ -105,7 +104,7 @@ class UserDataImport {
) {
const importStats = await this.importSshCredentials(
targetUserId,
exportData.userData.sshCredentials,
exportData.userData.sshCredentials as Record<string, unknown>[],
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshCredentialsImported = importStats.imported;
@@ -130,7 +129,7 @@ class UserDataImport {
) {
const importStats = await this.importDismissedAlerts(
targetUserId,
exportData.userData.dismissedAlerts,
exportData.userData.dismissedAlerts as Record<string, unknown>[],
{ replaceExisting, dryRun },
);
result.summary.dismissedAlertsImported = importStats.imported;
@@ -160,7 +159,7 @@ class UserDataImport {
private static async importSshHosts(
targetUserId: string,
sshHosts: any[],
sshHosts: Record<string, unknown>[],
options: {
replaceExisting: boolean;
dryRun: boolean;
@@ -199,7 +198,9 @@ class UserDataImport {
delete processedHostData.id;
await getDb().insert(sshData).values(processedHostData);
await getDb()
.insert(sshData)
.values(processedHostData as unknown as typeof sshData.$inferInsert);
imported++;
} catch (error) {
errors.push(
@@ -214,7 +215,7 @@ class UserDataImport {
private static async importSshCredentials(
targetUserId: string,
credentials: any[],
credentials: Record<string, unknown>[],
options: {
replaceExisting: boolean;
dryRun: boolean;
@@ -255,7 +256,11 @@ class UserDataImport {
delete processedCredentialData.id;
await getDb().insert(sshCredentials).values(processedCredentialData);
await getDb()
.insert(sshCredentials)
.values(
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
);
imported++;
} catch (error) {
errors.push(
@@ -270,7 +275,7 @@ class UserDataImport {
private static async importFileManagerData(
targetUserId: string,
fileManagerData: any,
fileManagerData: Record<string, unknown>,
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
@@ -357,7 +362,7 @@ class UserDataImport {
private static async importDismissedAlerts(
targetUserId: string,
alerts: any[],
alerts: Record<string, unknown>[],
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
@@ -377,7 +382,7 @@ class UserDataImport {
.where(
and(
eq(dismissedAlerts.userId, targetUserId),
eq(dismissedAlerts.alertId, alert.alertId),
eq(dismissedAlerts.alertId, alert.alertId as string),
),
);
@@ -396,10 +401,12 @@ class UserDataImport {
if (existing.length > 0 && options.replaceExisting) {
await getDb()
.update(dismissedAlerts)
.set(newAlert)
.set(newAlert as typeof dismissedAlerts.$inferInsert)
.where(eq(dismissedAlerts.id, existing[0].id));
} else {
await getDb().insert(dismissedAlerts).values(newAlert);
await getDb()
.insert(dismissedAlerts)
.values(newAlert as typeof dismissedAlerts.$inferInsert);
}
imported++;

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Chart Container
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
/>
);
});
ChartContainer.displayName = "ChartContainer";
export { ChartContainer, RechartsPrimitive };

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";

View File

@@ -5,8 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
type PasswordInputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const PasswordInput = React.forwardRef<
HTMLInputElement,

View File

@@ -17,10 +17,7 @@ export const Status = ({ className, status, ...props }: StatusProps) => (
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
export const StatusIndicator = ({
className,
...props
}: StatusIndicatorProps) => (
export const StatusIndicator = ({ ...props }: StatusIndicatorProps) => (
<span className="relative flex h-2 w-2" {...props}>
<span
className={cn(

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
@@ -8,13 +9,6 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@@ -26,7 +20,6 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -161,7 +154,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { state } = useSidebar();
if (collapsible === "none") {
return (
@@ -213,7 +206,6 @@ function Sidebar({
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -8,7 +8,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
const originalToast = toast;
const rateLimitedToast = (message: string, options?: any) => {
const rateLimitedToast = (
message: string,
options?: Record<string, unknown>,
) => {
const now = Date.now();
const lastToast = lastToastRef.current;
@@ -25,13 +28,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
};
Object.assign(toast, {
success: (message: string, options?: any) =>
success: (message: string, options?: Record<string, unknown>) =>
rateLimitedToast(message, { ...options, type: "success" }),
error: (message: string, options?: any) =>
error: (message: string, options?: Record<string, unknown>) =>
rateLimitedToast(message, { ...options, type: "error" }),
warning: (message: string, options?: any) =>
warning: (message: string, options?: Record<string, unknown>) =>
rateLimitedToast(message, { ...options, type: "warning" }),
info: (message: string, options?: any) =>
info: (message: string, options?: Record<string, unknown>) =>
rateLimitedToast(message, { ...options, type: "info" }),
message: rateLimitedToast,
});

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View File

@@ -0,0 +1,711 @@
export interface TerminalTheme {
name: string;
category: "dark" | "light" | "colorful";
colors: {
background: string;
foreground: string;
cursor?: string;
cursorAccent?: string;
selectionBackground?: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
};
}
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
termix: {
name: "Termix Default",
category: "dark",
colors: {
background: "#18181b",
foreground: "#f7f7f7",
cursor: "#f7f7f7",
cursorAccent: "#18181b",
selectionBackground: "#3a3a3d",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
},
dracula: {
name: "Dracula",
category: "dark",
colors: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
monokai: {
name: "Monokai",
category: "dark",
colors: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f0",
cursorAccent: "#272822",
selectionBackground: "#49483e",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
nord: {
name: "Nord",
category: "dark",
colors: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
gruvboxDark: {
name: "Gruvbox Dark",
category: "dark",
colors: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#504945",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
gruvboxLight: {
name: "Gruvbox Light",
category: "light",
colors: {
background: "#fbf1c7",
foreground: "#3c3836",
cursor: "#3c3836",
cursorAccent: "#fbf1c7",
selectionBackground: "#d5c4a1",
black: "#fbf1c7",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#7c6f64",
brightBlack: "#928374",
brightRed: "#9d0006",
brightGreen: "#79740e",
brightYellow: "#b57614",
brightBlue: "#076678",
brightMagenta: "#8f3f71",
brightCyan: "#427b58",
brightWhite: "#3c3836",
},
},
solarizedDark: {
name: "Solarized Dark",
category: "dark",
colors: {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
solarizedLight: {
name: "Solarized Light",
category: "light",
colors: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#657b83",
cursorAccent: "#fdf6e3",
selectionBackground: "#eee8d5",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
oneDark: {
name: "One Dark",
category: "dark",
colors: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "#3e4451",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
tokyoNight: {
name: "Tokyo Night",
category: "dark",
colors: {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#a9b1d6",
cursorAccent: "#1a1b26",
selectionBackground: "#283457",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
ayuDark: {
name: "Ayu Dark",
category: "dark",
colors: {
background: "#0a0e14",
foreground: "#b3b1ad",
cursor: "#e6b450",
cursorAccent: "#0a0e14",
selectionBackground: "#253340",
black: "#01060e",
red: "#ea6c73",
green: "#91b362",
yellow: "#f9af4f",
blue: "#53bdfa",
magenta: "#fae994",
cyan: "#90e1c6",
white: "#c7c7c7",
brightBlack: "#686868",
brightRed: "#f07178",
brightGreen: "#c2d94c",
brightYellow: "#ffb454",
brightBlue: "#59c2ff",
brightMagenta: "#ffee99",
brightCyan: "#95e6cb",
brightWhite: "#ffffff",
},
},
ayuLight: {
name: "Ayu Light",
category: "light",
colors: {
background: "#fafafa",
foreground: "#5c6166",
cursor: "#ff9940",
cursorAccent: "#fafafa",
selectionBackground: "#d1e4f4",
black: "#000000",
red: "#f51818",
green: "#86b300",
yellow: "#f2ae49",
blue: "#399ee6",
magenta: "#a37acc",
cyan: "#4cbf99",
white: "#c7c7c7",
brightBlack: "#686868",
brightRed: "#ff3333",
brightGreen: "#b8e532",
brightYellow: "#ffc849",
brightBlue: "#59c2ff",
brightMagenta: "#bf7ce0",
brightCyan: "#5cf7a0",
brightWhite: "#ffffff",
},
},
materialTheme: {
name: "Material Theme",
category: "dark",
colors: {
background: "#263238",
foreground: "#eeffff",
cursor: "#ffcc00",
cursorAccent: "#263238",
selectionBackground: "#546e7a",
black: "#000000",
red: "#e53935",
green: "#91b859",
yellow: "#ffb62c",
blue: "#6182b8",
magenta: "#7c4dff",
cyan: "#39adb5",
white: "#ffffff",
brightBlack: "#546e7a",
brightRed: "#ff5370",
brightGreen: "#c3e88d",
brightYellow: "#ffcb6b",
brightBlue: "#82aaff",
brightMagenta: "#c792ea",
brightCyan: "#89ddff",
brightWhite: "#ffffff",
},
},
palenight: {
name: "Palenight",
category: "dark",
colors: {
background: "#292d3e",
foreground: "#a6accd",
cursor: "#ffcc00",
cursorAccent: "#292d3e",
selectionBackground: "#676e95",
black: "#292d3e",
red: "#f07178",
green: "#c3e88d",
yellow: "#ffcb6b",
blue: "#82aaff",
magenta: "#c792ea",
cyan: "#89ddff",
white: "#d0d0d0",
brightBlack: "#434758",
brightRed: "#ff8b92",
brightGreen: "#ddffa7",
brightYellow: "#ffe585",
brightBlue: "#9cc4ff",
brightMagenta: "#e1acff",
brightCyan: "#a3f7ff",
brightWhite: "#ffffff",
},
},
oceanicNext: {
name: "Oceanic Next",
category: "dark",
colors: {
background: "#1b2b34",
foreground: "#cdd3de",
cursor: "#c0c5ce",
cursorAccent: "#1b2b34",
selectionBackground: "#343d46",
black: "#343d46",
red: "#ec5f67",
green: "#99c794",
yellow: "#fac863",
blue: "#6699cc",
magenta: "#c594c5",
cyan: "#5fb3b3",
white: "#cdd3de",
brightBlack: "#65737e",
brightRed: "#ec5f67",
brightGreen: "#99c794",
brightYellow: "#fac863",
brightBlue: "#6699cc",
brightMagenta: "#c594c5",
brightCyan: "#5fb3b3",
brightWhite: "#d8dee9",
},
},
nightOwl: {
name: "Night Owl",
category: "dark",
colors: {
background: "#011627",
foreground: "#d6deeb",
cursor: "#80a4c2",
cursorAccent: "#011627",
selectionBackground: "#1d3b53",
black: "#011627",
red: "#ef5350",
green: "#22da6e",
yellow: "#c5e478",
blue: "#82aaff",
magenta: "#c792ea",
cyan: "#21c7a8",
white: "#ffffff",
brightBlack: "#575656",
brightRed: "#ef5350",
brightGreen: "#22da6e",
brightYellow: "#ffeb95",
brightBlue: "#82aaff",
brightMagenta: "#c792ea",
brightCyan: "#7fdbca",
brightWhite: "#ffffff",
},
},
synthwave84: {
name: "Synthwave '84",
category: "colorful",
colors: {
background: "#241b2f",
foreground: "#f92aad",
cursor: "#f92aad",
cursorAccent: "#241b2f",
selectionBackground: "#495495",
black: "#000000",
red: "#f6188f",
green: "#1eff8e",
yellow: "#ffe261",
blue: "#03edf9",
magenta: "#f10596",
cyan: "#03edf9",
white: "#ffffff",
brightBlack: "#5a5a5a",
brightRed: "#ff1a8e",
brightGreen: "#1eff8e",
brightYellow: "#ffff00",
brightBlue: "#00d8ff",
brightMagenta: "#ff00d4",
brightCyan: "#00ffff",
brightWhite: "#ffffff",
},
},
cobalt2: {
name: "Cobalt2",
category: "dark",
colors: {
background: "#193549",
foreground: "#ffffff",
cursor: "#f0cc09",
cursorAccent: "#193549",
selectionBackground: "#0050a4",
black: "#000000",
red: "#ff0000",
green: "#38de21",
yellow: "#ffe50a",
blue: "#1460d2",
magenta: "#ff005d",
cyan: "#00bbbb",
white: "#bbbbbb",
brightBlack: "#555555",
brightRed: "#f40e17",
brightGreen: "#3bd01d",
brightYellow: "#edc809",
brightBlue: "#5555ff",
brightMagenta: "#ff55ff",
brightCyan: "#6ae3fa",
brightWhite: "#ffffff",
},
},
snazzy: {
name: "Snazzy",
category: "dark",
colors: {
background: "#282a36",
foreground: "#eff0eb",
cursor: "#97979b",
cursorAccent: "#282a36",
selectionBackground: "#97979b",
black: "#282a36",
red: "#ff5c57",
green: "#5af78e",
yellow: "#f3f99d",
blue: "#57c7ff",
magenta: "#ff6ac1",
cyan: "#9aedfe",
white: "#f1f1f0",
brightBlack: "#686868",
brightRed: "#ff5c57",
brightGreen: "#5af78e",
brightYellow: "#f3f99d",
brightBlue: "#57c7ff",
brightMagenta: "#ff6ac1",
brightCyan: "#9aedfe",
brightWhite: "#eff0eb",
},
},
atomOneDark: {
name: "Atom One Dark",
category: "dark",
colors: {
background: "#1e2127",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#1e2127",
selectionBackground: "#3e4451",
black: "#000000",
red: "#e06c75",
green: "#98c379",
yellow: "#d19a66",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#d19a66",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
catppuccinMocha: {
name: "Catppuccin Mocha",
category: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b70",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
};
// Font families available for terminal
export const TERMINAL_FONTS = [
{
value: "Caskaydia Cove Nerd Font Mono",
label: "Caskaydia Cove Nerd Font Mono",
fallback:
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "JetBrains Mono",
label: "JetBrains Mono",
fallback:
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Fira Code",
label: "Fira Code",
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Cascadia Code",
label: "Cascadia Code",
fallback:
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Source Code Pro",
label: "Source Code Pro",
fallback:
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "SF Mono",
label: "SF Mono",
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Consolas",
label: "Consolas",
fallback: 'Consolas, "Liberation Mono", monospace',
},
{
value: "Monaco",
label: "Monaco",
fallback: 'Monaco, "Liberation Mono", monospace',
},
];
export const CURSOR_STYLES = [
{ value: "block", label: "Block" },
{ value: "underline", label: "Underline" },
{ value: "bar", label: "Bar" },
] as const;
export const BELL_STYLES = [
{ value: "none", label: "None" },
{ value: "sound", label: "Sound" },
{ value: "visual", label: "Visual" },
{ value: "both", label: "Both" },
] as const;
export const FAST_SCROLL_MODIFIERS = [
{ value: "alt", label: "Alt" },
{ value: "ctrl", label: "Ctrl" },
{ value: "shift", label: "Shift" },
] as const;
export const DEFAULT_TERMINAL_CONFIG = {
cursorBlink: true,
cursorStyle: "bar" as const,
fontSize: 14,
fontFamily: "Caskaydia Cove Nerd Font Mono",
letterSpacing: 0,
lineHeight: 1.2,
theme: "termix",
scrollback: 10000,
bellStyle: "none" as const,
rightClickSelectsWord: false,
fastScrollModifier: "alt" as const,
fastScrollSensitivity: 5,
minimumContrastRatio: 1,
backspaceMode: "normal" as const,
agentForwarding: false,
environmentVariables: [] as Array<{ key: string; value: string }>,
startupSnippetId: null as number | null,
autoMosh: false,
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
};
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;

View File

@@ -5,12 +5,14 @@ import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en/translation.json";
import zhTranslation from "../locales/zh/translation.json";
import deTranslation from "../locales/de/translation.json";
import ptbrTranslation from "../locales/pt-BR/translation.json";
import ruTranslation from "../locales/ru/translation.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs: ["en", "zh", "de"],
supportedLngs: ["en", "zh", "de", "ptbr", "ru"],
fallbackLng: "en",
debug: false,
@@ -32,6 +34,12 @@ i18n
de: {
translation: deTranslation,
},
ptbr: {
translation: ptbrTranslation,
},
ru: {
translation: ruTranslation,
},
},
interpolation: {

View File

@@ -17,7 +17,7 @@ export interface LogContext {
errorCode?: string;
errorMessage?: string;
[key: string]: any;
[key: string]: unknown;
}
class FrontendLogger {
@@ -218,7 +218,6 @@ class FrontendLogger {
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
@@ -244,7 +243,6 @@ class FrontendLogger {
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
@@ -265,7 +263,6 @@ class FrontendLogger {
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context,
@@ -279,7 +276,6 @@ class FrontendLogger {
authError(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, {
...context,
@@ -298,7 +294,6 @@ class FrontendLogger {
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context,
@@ -384,5 +379,6 @@ export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
export const logger = systemLogger;

View File

@@ -311,6 +311,8 @@
"next": "Weiter",
"previous": "Vorherige",
"refresh": "Aktualisieren",
"connect": "Verbinden",
"connecting": "Verbinde...",
"settings": "Einstellungen",
"profile": "Profil",
"help": "Hilfe",
@@ -318,6 +320,8 @@
"language": "Sprache",
"autoDetect": "Automatische Erkennung",
"changeAccountPassword": "Passwort für Ihr Konto ändern",
"passwordResetTitle": "Passwort zurücksetzen",
"passwordResetDescription": "Sie sind dabei, Ihr Passwort zurückzusetzen. Dadurch werden Sie von allen aktiven Sitzungen abgemeldet.",
"enterSixDigitCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen \/ logs für den Benutzer ein:",
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
@@ -529,7 +533,19 @@
"passwordRequired": "Passwort erforderlich",
"confirmExport": "Export bestätigen",
"exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren",
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)"
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)",
"criticalWarning": "Kritische Warnung",
"cannotDisablePasswordLoginWithoutOIDC": "Passwort-Login kann nicht ohne konfiguriertes OIDC deaktiviert werden! Sie müssen die OIDC-Authentifizierung konfigurieren, bevor Sie die Passwort-Anmeldung deaktivieren, sonst verlieren Sie den Zugriff auf Termix.",
"confirmDisablePasswordLogin": "Sind Sie sicher, dass Sie die Passwort-Anmeldung deaktivieren möchten? Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist und funktioniert, bevor Sie fortfahren, sonst verlieren Sie den Zugriff auf Ihre Termix-Instanz.",
"passwordLoginDisabled": "Passwort-Login erfolgreich deaktiviert",
"passwordLoginAndRegistrationDisabled": "Passwort-Login und Registrierung neuer Konten erfolgreich deaktiviert",
"requiresPasswordLogin": "Erfordert aktivierte Passwort-Anmeldung",
"passwordLoginDisabledWarning": "Passwort-Login ist deaktiviert. Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist, sonst können Sie sich nicht bei Termix anmelden.",
"oidcRequiredWarning": "KRITISCH: Passwort-Login ist deaktiviert. Wenn Sie OIDC zurücksetzen oder falsch konfigurieren, verlieren Sie den gesamten Zugriff auf Termix und Ihre Instanz wird unbrauchbar. Fahren Sie nur fort, wenn Sie absolut sicher sind.",
"confirmDisableOIDCWarning": "WARNUNG: Sie sind dabei, OIDC zu deaktivieren, während auch die Passwort-Anmeldung deaktiviert ist. Dies macht Ihre Termix-Instanz unbrauchbar und Sie verlieren den gesamten Zugriff. Sind Sie absolut sicher, dass Sie fortfahren möchten?",
"allowPasswordLogin": "Benutzername/Passwort-Anmeldung zulassen",
"failedToFetchPasswordLoginStatus": "Abrufen des Passwort-Login-Status fehlgeschlagen",
"failedToUpdatePasswordLoginStatus": "Aktualisierung des Passwort-Login-Status fehlgeschlagen"
},
"hosts": {
"title": "Host-Manager",
@@ -623,6 +639,7 @@
"password": "Passwort",
"key": "Schlüssel",
"credential": "Anmeldedaten",
"none": "Keine",
"selectCredential": "Anmeldeinformationen auswählen",
"selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
"credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
@@ -659,7 +676,34 @@
"folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
"failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
"movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben",
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden"
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden",
"statistics": "Statistiken",
"enabledWidgets": "Aktivierte Widgets",
"enabledWidgetsDesc": "Wählen Sie aus, welche Statistik-Widgets für diesen Host angezeigt werden sollen",
"monitoringConfiguration": "Überwachungskonfiguration",
"monitoringConfigurationDesc": "Konfigurieren Sie, wie oft Serverstatistiken und Status überprüft werden",
"statusCheckEnabled": "Statusüberwachung aktivieren",
"statusCheckEnabledDesc": "Prüfen Sie, ob der Server online oder offline ist",
"statusCheckInterval": "Statusprüfintervall",
"statusCheckIntervalDesc": "Wie oft überprüft werden soll, ob der Host online ist (5s - 1h)",
"metricsEnabled": "Metriküberwachung aktivieren",
"metricsEnabledDesc": "CPU-, RAM-, Festplatten- und andere Systemstatistiken erfassen",
"metricsInterval": "Metriken-Erfassungsintervall",
"metricsIntervalDesc": "Wie oft Serverstatistiken erfasst werden sollen (5s - 1h)",
"intervalSeconds": "Sekunden",
"intervalMinutes": "Minuten",
"intervalValidation": "Überwachungsintervalle müssen zwischen 5 Sekunden und 1 Stunde (3600 Sekunden) liegen",
"monitoringDisabled": "Die Serverüberwachung ist für diesen Host deaktiviert",
"enableMonitoring": "Überwachung aktivieren in Host-Manager → Statistiken-Tab",
"monitoringDisabledBadge": "Überwachung Aus",
"statusMonitoring": "Status",
"metricsMonitoring": "Metriken",
"terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.",
"noneAuthTitle": "Keyboard-Interactive-Authentifizierung",
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.",
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.",
"forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen",
"forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden."
},
"terminal": {
"title": "Terminal",
@@ -1132,7 +1176,18 @@
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
"passwordResetSuccess": "Erfolgreich!",
"passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
"signUp": "Registrierung"
"signUp": "Registrierung",
"dataLossWarning": "Wenn Sie Ihr Passwort auf diese Weise zurücksetzen, werden alle Ihre gespeicherten SSH-Hosts, Anmeldeinformationen und andere verschlüsselte Daten gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. Verwenden Sie diese Option nur, wenn Sie Ihr Passwort vergessen haben und nicht angemeldet sind.",
"sshAuthenticationRequired": "SSH-Authentifizierung erforderlich",
"sshNoKeyboardInteractive": "Keyboard-Interactive-Authentifizierung nicht verfügbar",
"sshAuthenticationFailed": "Authentifizierung fehlgeschlagen",
"sshAuthenticationTimeout": "Authentifizierungs-Timeout",
"sshNoKeyboardInteractiveDescription": "Der Server unterstützt keine Keyboard-Interactive-Authentifizierung. Bitte geben Sie Ihr Passwort oder Ihren SSH-Schlüssel ein.",
"sshAuthFailedDescription": "Die angegebenen Anmeldeinformationen waren falsch. Bitte versuchen Sie es erneut mit gültigen Anmeldeinformationen.",
"sshTimeoutDescription": "Der Authentifizierungsversuch ist abgelaufen. Bitte versuchen Sie es erneut.",
"sshProvideCredentialsDescription": "Bitte geben Sie Ihre SSH-Anmeldeinformationen ein, um eine Verbindung zu diesem Server herzustellen.",
"sshPasswordDescription": "Geben Sie das Passwort für diese SSH-Verbindung ein.",
"sshKeyPasswordDescription": "Wenn Ihr SSH-Schlüssel verschlüsselt ist, geben Sie hier die Passphrase ein."
},
"errors": {
"notFound": "Seite nicht gefunden",
@@ -1158,6 +1213,7 @@
"maxLength": "Die maximale Länge beträgt {{max}}",
"invalidEmail": "Ungültige E-Mail-Adresse",
"passwordMismatch": "Passwörter stimmen nicht überein",
"passwordLoginDisabled": "Benutzername/Passwort-Anmeldung ist derzeit deaktiviert",
"weakPassword": "Das Passwort ist zu schwach",
"usernameExists": "Benutzername existiert bereits",
"emailExists": "E-Mail existiert bereits",
@@ -1203,7 +1259,10 @@
"authMethod": "Authentifizierungsmethode",
"local": "Lokal",
"external": "Extern (OIDC)",
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche"
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche",
"currentPassword": "Aktuelles Passwort",
"passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.",
"failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut."
},
"user": {
"failedToLoadVersionInfo": "Fehler beim Laden der Versionsinformationen"
@@ -1261,7 +1320,8 @@
"deleteAccount": "Konto löschen",
"closeDeleteAccount": "Schließen Konto löschen",
"deleteAccountWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden Ihr Konto und alle damit verbundenen Daten dauerhaft gelöscht.",
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion ist nicht rückgängig zu machen.",
"deleteAccountWarningShort": "Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr Konto dauerhaft.",
"cannotDeleteAccount": "Konto kann nicht gelöscht werden",
"lastAdminWarning": "Sie sind der letzte Administrator. Sie können Ihr Konto nicht löschen, da das System dann ohne Administratoren wäre. Bitte benennen Sie zunächst einen anderen Benutzer als Administrator oder wenden Sie sich an den Systemsupport.",
"confirmPassword": "Passwort bestätigen",
@@ -1381,5 +1441,38 @@
"mobileAppInProgressDesc": "Wir arbeiten an einer speziellen mobilen App, um ein besseres Erlebnis auf Mobilgeräten zu bieten.",
"viewMobileAppDocs": "Mobile App installieren",
"mobileAppDocumentation": "Mobile App-Dokumentation"
},
"dashboard": {
"title": "Dashboard",
"github": "GitHub",
"support": "Support",
"discord": "Discord",
"donate": "Spenden",
"serverOverview": "Serverübersicht",
"version": "Version",
"upToDate": "Auf dem neuesten Stand",
"updateAvailable": "Update verfügbar",
"uptime": "Betriebszeit",
"database": "Datenbank",
"healthy": "Gesund",
"error": "Fehler",
"totalServers": "Server gesamt",
"totalTunnels": "Tunnel gesamt",
"totalCredentials": "Anmeldedaten gesamt",
"recentActivity": "Kürzliche Aktivität",
"reset": "Zurücksetzen",
"loadingRecentActivity": "Kürzliche Aktivität wird geladen...",
"noRecentActivity": "Keine kürzliche Aktivität",
"quickActions": "Schnellaktionen",
"addHost": "Host hinzufügen",
"addCredential": "Anmeldedaten hinzufügen",
"adminSettings": "Admin-Einstellungen",
"userProfile": "Benutzerprofil",
"serverStats": "Serverstatistiken",
"loadingServerStats": "Serverstatistiken werden geladen...",
"noServerData": "Keine Serverdaten verfügbar",
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "Nicht verfügbar"
}
}

View File

@@ -54,7 +54,7 @@
"sshPrivateKey": "SSH Private Key",
"upload": "Upload",
"updateKey": "Update Key",
"keyPassword": "Key Password (optional)",
"keyPassword": "Key Password",
"keyType": "Key Type",
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
@@ -191,6 +191,40 @@
"enableRightClickCopyPaste": "Enable rightclick copy/paste",
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on"
},
"snippets": {
"title": "Snippets",
"new": "New Snippet",
"create": "Create Snippet",
"edit": "Edit Snippet",
"run": "Run",
"empty": "No snippets yet",
"emptyHint": "Create a snippet to save commonly used commands",
"name": "Name",
"description": "Description",
"content": "Command",
"namePlaceholder": "e.g., Restart Nginx",
"descriptionPlaceholder": "Optional description",
"contentPlaceholder": "e.g., sudo systemctl restart nginx",
"nameRequired": "Name is required",
"contentRequired": "Command is required",
"createDescription": "Create a new command snippet for quick execution",
"editDescription": "Edit this command snippet",
"deleteConfirmTitle": "Delete Snippet",
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"?",
"createSuccess": "Snippet created successfully",
"updateSuccess": "Snippet updated successfully",
"deleteSuccess": "Snippet deleted successfully",
"createFailed": "Failed to create snippet",
"updateFailed": "Failed to update snippet",
"deleteFailed": "Failed to delete snippet",
"failedToFetch": "Failed to fetch snippets",
"executeSuccess": "Executing: {{name}}",
"copySuccess": "Copied \"{{name}}\" to clipboard",
"runTooltip": "Execute this snippet in the terminal",
"copyTooltip": "Copy snippet to clipboard",
"editTooltip": "Edit this snippet",
"deleteTooltip": "Delete this snippet"
},
"homepage": {
"loggedInTitle": "Logged in!",
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
@@ -213,7 +247,11 @@
"saveError": "Error saving configuration",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)",
"warning": "Warning",
"notValidatedWarning": "URL not validated - ensure it's correct",
"changeServer": "Change Server",
"mustIncludeProtocol": "Server URL must start with http:// or https://"
},
"versionCheck": {
"error": "Version Check Error",
@@ -249,6 +287,8 @@
"loading": "Loading",
"required": "Required",
"optional": "Optional",
"connect": "Connect",
"connecting": "Connecting...",
"clear": "Clear",
"toggleSidebar": "Toggle Sidebar",
"sidebar": "Sidebar",
@@ -333,6 +373,8 @@
"language": "Language",
"autoDetect": "Auto-detect",
"changeAccountPassword": "Change your account password",
"passwordResetTitle": "Password Reset",
"passwordResetDescription": "You are about to reset your password. This will log you out of all active sessions.",
"enterSixDigitCode": "Enter the 6-digit code from the docker container logs for user:",
"enterNewPassword": "Enter your new password for user:",
"passwordsDoNotMatch": "Passwords do not match",
@@ -357,6 +399,7 @@
"admin": "Admin",
"userProfile": "User Profile",
"tools": "Tools",
"snippets": "Snippets",
"newTab": "New Tab",
"splitScreen": "Split Screen",
"closeTab": "Close Tab",
@@ -410,10 +453,12 @@
"general": "General",
"userRegistration": "User Registration",
"allowNewAccountRegistration": "Allow new account registration",
"allowPasswordLogin": "Allow username/password login",
"missingRequiredFields": "Missing required fields: {{fields}}",
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
"failedToFetchRegistrationStatus": "Failed to fetch registration status",
"failedToFetchPasswordLoginStatus": "Failed to fetch password login status",
"failedToFetchUsers": "Failed to fetch users",
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
@@ -428,6 +473,13 @@
"userDeletedSuccessfully": "User {{username}} deleted successfully",
"failedToDeleteUser": "Failed to delete user",
"overrideUserInfoUrl": "Override User Info URL (not required)",
"failedToFetchSessions": "Failed to fetch sessions",
"sessionRevokedSuccessfully": "Session revoked successfully",
"failedToRevokeSession": "Failed to revoke session",
"confirmRevokeSession": "Are you sure you want to revoke this session?",
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
"failedToRevokeSessions": "Failed to revoke sessions",
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
"databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled",
@@ -546,7 +598,16 @@
"passwordRequired": "Password required",
"confirmExport": "Confirm Export",
"exportDescription": "Export SSH hosts and credentials as SQLite file",
"importDescription": "Import SQLite file with incremental merge (skips duplicates)"
"importDescription": "Import SQLite file with incremental merge (skips duplicates)",
"criticalWarning": "Critical Warning",
"cannotDisablePasswordLoginWithoutOIDC": "Cannot disable password login without OIDC configured! You must configure OIDC authentication before disabling password login, or you will lose access to Termix.",
"confirmDisablePasswordLogin": "Are you sure you want to disable password login? Make sure OIDC is properly configured and working before proceeding, or you will lose access to your Termix instance.",
"passwordLoginDisabled": "Password login disabled successfully",
"passwordLoginAndRegistrationDisabled": "Password login and new account registration disabled successfully",
"requiresPasswordLogin": "Requires password login enabled",
"passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.",
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?"
},
"hosts": {
"title": "Host Manager",
@@ -640,6 +701,7 @@
"password": "Password",
"key": "Key",
"credential": "Credential",
"none": "None",
"selectCredential": "Select Credential",
"selectCredentialPlaceholder": "Choose a credential...",
"credentialRequired": "Credential is required when using credential authentication",
@@ -669,14 +731,58 @@
"terminal": "Terminal",
"tunnel": "Tunnel",
"fileManager": "File Manager",
"serverStats": "Server Stats",
"hostViewer": "Host Viewer",
"enableServerStats": "Enable Server Stats",
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
"displayItems": "Display Items",
"displayItemsDesc": "Choose which metrics to display on the server stats page",
"enableCpu": "CPU Usage",
"enableMemory": "Memory Usage",
"enableDisk": "Disk Usage",
"enableNetwork": "Network Statistics (Coming Soon)",
"enableProcesses": "Process Count (Coming Soon)",
"enableUptime": "Uptime (Coming Soon)",
"enableHostname": "Hostname (Coming Soon)",
"enableOs": "Operating System (Coming Soon)",
"customCommands": "Custom Commands (Coming Soon)",
"customCommandsDesc": "Define custom shutdown and reboot commands for this server",
"shutdownCommand": "Shutdown Command",
"rebootCommand": "Reboot Command",
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
"failedToRemoveFromFolder": "Failed to remove host from folder",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move host to folder"
"failedToMoveToFolder": "Failed to move host to folder",
"statistics": "Statistics",
"enabledWidgets": "Enabled Widgets",
"enabledWidgetsDesc": "Select which statistics widgets to display for this host",
"monitoringConfiguration": "Monitoring Configuration",
"monitoringConfigurationDesc": "Configure how often server statistics and status are checked",
"statusCheckEnabled": "Enable Status Monitoring",
"statusCheckEnabledDesc": "Check if the server is online or offline",
"statusCheckInterval": "Status Check Interval",
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
"metricsEnabled": "Enable Metrics Monitoring",
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
"metricsInterval": "Metrics Collection Interval",
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
"intervalSeconds": "seconds",
"intervalMinutes": "minutes",
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
"monitoringDisabled": "Server monitoring is disabled for this host",
"enableMonitoring": "Enable monitoring in Host Manager → Statistics tab",
"monitoringDisabledBadge": "Monitoring Off",
"statusMonitoring": "Status",
"metricsMonitoring": "Metrics",
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
"noneAuthTitle": "Keyboard-Interactive Authentication",
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
"forceKeyboardInteractive": "Force Keyboard-Interactive",
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)."
},
"terminal": {
"title": "Terminal",
@@ -710,7 +816,11 @@
"connectionTimeout": "Connection timeout",
"terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
"runTitle": "Running {{command}} - {{host}}"
"runTitle": "Running {{command}} - {{host}}",
"totpRequired": "Two-Factor Authentication Required",
"totpCodeLabel": "Verification Code",
"totpPlaceholder": "000000",
"totpVerify": "Verify"
},
"fileManager": {
"title": "File Manager",
@@ -994,7 +1104,9 @@
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}"
"loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
},
"tunnels": {
"title": "SSH Tunnels",
@@ -1083,6 +1195,7 @@
"loadAverageNA": "Avg: N/A",
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
"diskUsage": "Disk Usage",
"rootStorageSpace": "Root Storage Space",
"of": "of",
"feedbackMessage": "Have ideas for what should come next for server management? Share them on",
@@ -1094,9 +1207,29 @@
"refreshing": "Refreshing...",
"serverOffline": "Server Offline",
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"free": "Free",
"available": "Available"
"available": "Available",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
"addWidget": "Add Widget",
"saveLayout": "Save Layout",
"unsavedChanges": "Unsaved changes",
"layoutSaved": "Layout saved successfully",
"failedToSaveLayout": "Failed to save layout",
"systemInfo": "System Information",
"hostname": "Hostname",
"operatingSystem": "Operating System",
"kernel": "Kernel",
"totalUptime": "Total Uptime",
"seconds": "seconds",
"networkInterfaces": "Network Interfaces",
"noInterfacesFound": "No network interfaces found",
"totalProcesses": "Total Processes",
"running": "Running",
"noProcessesFound": "No processes found"
},
"auth": {
"loginTitle": "Login to Termix",
@@ -1119,6 +1252,7 @@
"enterCode": "Enter verification code",
"backupCode": "Or use backup code",
"verifyCode": "Verify Code",
"redirectingToApp": "Redirecting to app...",
"enableTwoFactor": "Enable Two-Factor Authentication",
"disableTwoFactor": "Disable Two-Factor Authentication",
"scanQRCode": "Scan this QR code with your authenticator app",
@@ -1146,6 +1280,16 @@
"yourBackupCodes": "Your Backup Codes",
"download": "Download",
"setupTwoFactorTitle": "Set Up Two-Factor Authentication",
"sshAuthenticationRequired": "SSH Authentication Required",
"sshNoKeyboardInteractive": "Keyboard-Interactive Authentication Unavailable",
"sshAuthenticationFailed": "Authentication Failed",
"sshAuthenticationTimeout": "Authentication Timeout",
"sshNoKeyboardInteractiveDescription": "The server does not support keyboard-interactive authentication. Please provide your password or SSH key.",
"sshAuthFailedDescription": "The provided credentials were incorrect. Please try again with valid credentials.",
"sshTimeoutDescription": "The authentication attempt timed out. Please try again.",
"sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.",
"sshPasswordDescription": "Enter the password for this SSH connection.",
"sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.",
"step1ScanQR": "Step 1: Scan the QR code with your authenticator app",
"manualEntryCode": "Manual Entry Code",
"cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app",
@@ -1178,9 +1322,17 @@
"newPassword": "New Password",
"confirmNewPassword": "Confirm Password",
"enterNewPassword": "Enter your new password for user:",
"passwordResetSuccess": "Success!",
"passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.",
"signUp": "Sign Up"
"signUp": "Sign Up",
"mobileApp": "Mobile App",
"loggingInToMobileApp": "Logging in to the mobile app",
"desktopApp": "Desktop App",
"loggingInToDesktopApp": "Logging in to the desktop app",
"loggingInToDesktopAppViaWeb": "Logging in to the desktop app via web interface",
"loadingServer": "Loading server...",
"authenticating": "Authenticating...",
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
"authenticationDisabled": "Authentication Disabled",
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
},
"errors": {
"notFound": "Page not found",
@@ -1188,7 +1340,7 @@
"forbidden": "Access forbidden",
"serverError": "Server error",
"networkError": "Network error",
"databaseConnection": "Could not connect to the database.",
"databaseConnection": "Could not connect to the database",
"unknownError": "Unknown error",
"loginFailed": "Login failed",
"failedPasswordReset": "Failed to initiate password reset",
@@ -1206,6 +1358,7 @@
"maxLength": "Maximum length is {{max}}",
"invalidEmail": "Invalid email address",
"passwordMismatch": "Passwords do not match",
"passwordLoginDisabled": "Username/password login is currently disabled",
"weakPassword": "Password is too weak",
"usernameExists": "Username already exists",
"emailExists": "Email already exists",
@@ -1251,7 +1404,10 @@
"authMethod": "Authentication Method",
"local": "Local",
"external": "External (OIDC)",
"selectPreferredLanguage": "Select your preferred language for the interface"
"selectPreferredLanguage": "Select your preferred language for the interface",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
},
"user": {
"failedToLoadVersionInfo": "Failed to load version information"
@@ -1310,6 +1466,7 @@
"closeDeleteAccount": "Close Delete Account",
"deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.",
"deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.",
"deleteAccountWarningShort": "This action is not reversible and will permanently delete your account.",
"cannotDeleteAccount": "Cannot Delete Account",
"lastAdminWarning": "You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support.",
"confirmPassword": "Confirm Password",
@@ -1430,5 +1587,38 @@
"mobileAppInProgressDesc": "We're working on a dedicated mobile app to provide a better experience on mobile devices.",
"viewMobileAppDocs": "Install Mobile App",
"mobileAppDocumentation": "Mobile App Documentation"
},
"dashboard": {
"title": "Dashboard",
"github": "GitHub",
"support": "Support",
"discord": "Discord",
"donate": "Donate",
"serverOverview": "Server Overview",
"version": "Version",
"upToDate": "Up to Date",
"updateAvailable": "Update Available",
"uptime": "Uptime",
"database": "Database",
"healthy": "Healthy",
"error": "Error",
"totalServers": "Total Servers",
"totalTunnels": "Total Tunnels",
"totalCredentials": "Total Credentials",
"recentActivity": "Recent Activity",
"reset": "Reset",
"loadingRecentActivity": "Loading recent activity...",
"noRecentActivity": "No recent activity",
"quickActions": "Quick Actions",
"addHost": "Add Host",
"addCredential": "Add Credential",
"adminSettings": "Admin Settings",
"userProfile": "User Profile",
"serverStats": "Server Stats",
"loadingServerStats": "Loading server stats...",
"noServerData": "No server data available",
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "N/A"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -189,6 +189,40 @@
"enableRightClickCopyPaste": "启用右键复制/粘贴",
"shareIdeas": "对 SSH 工具有什么想法?在此分享"
},
"snippets": {
"title": "代码片段",
"new": "新建片段",
"create": "创建代码片段",
"edit": "编辑代码片段",
"run": "运行",
"empty": "暂无代码片段",
"emptyHint": "创建代码片段以保存常用命令",
"name": "名称",
"description": "描述",
"content": "命令",
"namePlaceholder": "例如: 重启 Nginx",
"descriptionPlaceholder": "可选描述",
"contentPlaceholder": "例如: sudo systemctl restart nginx",
"nameRequired": "名称不能为空",
"contentRequired": "命令不能为空",
"createDescription": "创建新的命令片段以便快速执行",
"editDescription": "编辑此命令片段",
"deleteConfirmTitle": "删除代码片段",
"deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?",
"createSuccess": "代码片段创建成功",
"updateSuccess": "代码片段更新成功",
"deleteSuccess": "代码片段删除成功",
"createFailed": "创建代码片段失败",
"updateFailed": "更新代码片段失败",
"deleteFailed": "删除代码片段失败",
"failedToFetch": "获取代码片段失败",
"executeSuccess": "正在执行: {{name}}",
"copySuccess": "已复制 \"{{name}}\" 到剪贴板",
"runTooltip": "在终端中执行此片段",
"copyTooltip": "复制片段到剪贴板",
"editTooltip": "编辑此片段",
"deleteTooltip": "删除此片段"
},
"homepage": {
"loggedInTitle": "登录成功!",
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
@@ -311,6 +345,8 @@
"settingUp": "设置中...",
"next": "下一步",
"previous": "上一步",
"connect": "连接",
"connecting": "连接中...",
"refresh": "刷新",
"settings": "设置",
"profile": "个人资料",
@@ -319,6 +355,8 @@
"language": "语言",
"autoDetect": "自动检测",
"changeAccountPassword": "修改您的账户密码",
"passwordResetTitle": "重置密码",
"passwordResetDescription": "您即将重置密码。此操作将使您从所有活动会话中注销。",
"enterSixDigitCode": "输入来自 docker 容器日志中用户的 6 位数代码:",
"enterNewPassword": "为用户输入新密码:",
"passwordsDoNotMatch": "密码不匹配",
@@ -343,6 +381,7 @@
"admin": "管理员",
"userProfile": "用户资料",
"tools": "工具",
"snippets": "代码片段",
"newTab": "新标签页",
"splitScreen": "分屏",
"closeTab": "关闭标签页",
@@ -396,10 +435,12 @@
"general": "常规",
"userRegistration": "用户注册",
"allowNewAccountRegistration": "允许新账户注册",
"allowPasswordLogin": "允许用户名/密码登录",
"missingRequiredFields": "缺少必填字段:{{fields}}",
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
"failedToFetchOidcConfig": "获取 OIDC 配置失败",
"failedToFetchRegistrationStatus": "获取注册状态失败",
"failedToFetchPasswordLoginStatus": "获取密码登录状态失败",
"failedToFetchUsers": "获取用户列表失败",
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
@@ -530,7 +571,17 @@
"passwordRequired": "密码为必填项",
"confirmExport": "确认导出",
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
"importDescription": "导入SQLite文件并进行增量合并跳过重复项"
"importDescription": "导入SQLite文件并进行增量合并跳过重复项",
"criticalWarning": "严重警告",
"cannotDisablePasswordLoginWithoutOIDC": "无法在未配置 OIDC 的情况下禁用密码登录!您必须先配置 OIDC 认证,然后再禁用密码登录,否则您将失去对 Termix 的访问权限。",
"confirmDisablePasswordLogin": "您确定要禁用密码登录吗?在继续之前,请确保 OIDC 已正确配置并且正常工作,否则您将失去对 Termix 实例的访问权限。",
"passwordLoginDisabled": "密码登录已成功禁用",
"passwordLoginAndRegistrationDisabled": "密码登录和新账户注册已成功禁用",
"requiresPasswordLogin": "需要启用密码登录",
"passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。",
"oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。",
"confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?",
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败"
},
"hosts": {
"title": "主机管理",
@@ -636,6 +687,7 @@
"password": "密码",
"key": "密钥",
"credential": "凭证",
"none": "无",
"selectCredential": "选择凭证",
"selectCredentialPlaceholder": "选择一个凭证...",
"credentialRequired": "使用凭证认证时需要选择凭证",
@@ -691,19 +743,68 @@
"terminal": "终端",
"tunnel": "隧道",
"fileManager": "文件管理器",
"serverStats": "服务器统计",
"hostViewer": "主机查看器",
"enableServerStats": "启用服务器统计",
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
"displayItems": "显示项目",
"displayItemsDesc": "选择在服务器统计页面上显示哪些指标",
"enableCpu": "CPU使用率",
"enableMemory": "内存使用率",
"enableDisk": "磁盘使用率",
"enableNetwork": "网络统计(即将推出)",
"enableProcesses": "进程数(即将推出)",
"enableUptime": "运行时间(即将推出)",
"enableHostname": "主机名(即将推出)",
"enableOs": "操作系统(即将推出)",
"customCommands": "自定义命令(即将推出)",
"customCommandsDesc": "为此服务器定义自定义关机和重启命令",
"shutdownCommand": "关机命令",
"rebootCommand": "重启命令",
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动主机到文件夹失败"
"failedToMoveToFolder": "移动主机到文件夹失败",
"statistics": "统计",
"enabledWidgets": "已启用组件",
"enabledWidgetsDesc": "选择要为此主机显示的统计组件",
"monitoringConfiguration": "监控配置",
"monitoringConfigurationDesc": "配置服务器统计信息和状态的检查频率",
"statusCheckEnabled": "启用状态监控",
"statusCheckEnabledDesc": "检查服务器是在线还是离线",
"statusCheckInterval": "状态检查间隔",
"statusCheckIntervalDesc": "检查主机是否在线的频率 (5秒 - 1小时)",
"metricsEnabled": "启用指标监控",
"metricsEnabledDesc": "收集CPU、内存、磁盘和其他系统统计信息",
"metricsInterval": "指标收集间隔",
"metricsIntervalDesc": "收集服务器统计信息的频率 (5秒 - 1小时)",
"intervalSeconds": "秒",
"intervalMinutes": "分钟",
"intervalValidation": "监控间隔必须在 5 秒到 1 小时3600 秒)之间",
"monitoringDisabled": "此主机的服务器监控已禁用",
"enableMonitoring": "在主机管理器 → 统计选项卡中启用监控",
"monitoringDisabledBadge": "监控已关闭",
"statusMonitoring": "状态",
"metricsMonitoring": "指标",
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
"noneAuthTitle": "键盘交互式认证",
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
"forceKeyboardInteractive": "强制键盘交互式认证",
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证TOTP/2FA的服务器所必需的。"
},
"terminal": {
"title": "终端",
"terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}",
"totpRequired": "需要双因素认证",
"totpCodeLabel": "验证码",
"totpPlaceholder": "000000",
"totpVerify": "验证",
"connect": "连接主机",
"disconnect": "断开连接",
"clear": "清屏",
@@ -985,7 +1086,9 @@
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}"
"loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1063,6 +1166,7 @@
"loadAverageNA": "平均: N/A",
"cpuUsage": "CPU 使用率",
"memoryUsage": "内存使用率",
"diskUsage": "磁盘使用率",
"rootStorageSpace": "根目录存储空间",
"of": "的",
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
@@ -1073,7 +1177,29 @@
"refreshing": "正在刷新...",
"serverOffline": "服务器离线",
"cannotFetchMetrics": "无法从离线服务器获取指标",
"load": "负载"
"totpRequired": "需要 TOTP 认证",
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载",
"free": "空闲",
"available": "可用",
"editLayout": "编辑布局",
"cancelEdit": "取消",
"addWidget": "添加小组件",
"saveLayout": "保存布局",
"unsavedChanges": "有未保存的更改",
"layoutSaved": "布局保存成功",
"failedToSaveLayout": "保存布局失败",
"systemInfo": "系统信息",
"hostname": "主机名",
"operatingSystem": "操作系统",
"kernel": "内核",
"totalUptime": "总运行时间",
"seconds": "秒",
"networkInterfaces": "网络接口",
"noInterfacesFound": "未找到网络接口",
"totalProcesses": "总进程数",
"running": "运行中",
"noProcessesFound": "未找到进程"
},
"auth": {
"loginTitle": "登录 Termix",
@@ -1157,7 +1283,18 @@
"enterNewPassword": "为用户输入新密码:",
"passwordResetSuccess": "成功!",
"passwordResetSuccessDesc": "您的密码已成功重置!您现在可以使用新密码登录。",
"signUp": "注册"
"signUp": "注册",
"dataLossWarning": "以这种方式重置密码将删除所有已保存的 SSH 主机、凭据和其他加密数据。此操作无法撤销。仅当您忘记密码且未登录时才使用此功能。",
"sshAuthenticationRequired": "需要 SSH 身份验证",
"sshNoKeyboardInteractive": "键盘交互式身份验证不可用",
"sshAuthenticationFailed": "身份验证失败",
"sshAuthenticationTimeout": "身份验证超时",
"sshNoKeyboardInteractiveDescription": "服务器不支持键盘交互式身份验证。请提供您的密码或 SSH 密钥。",
"sshAuthFailedDescription": "提供的凭据不正确。请使用有效凭据重试。",
"sshTimeoutDescription": "身份验证尝试超时。请重试。",
"sshProvideCredentialsDescription": "请提供您的 SSH 凭据以连接到此服务器。",
"sshPasswordDescription": "输入此 SSH 连接的密码。",
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。"
},
"errors": {
"notFound": "页面未找到",
@@ -1183,6 +1320,7 @@
"maxLength": "最大长度为 {{max}}",
"invalidEmail": "邮箱地址无效",
"passwordMismatch": "密码不匹配",
"passwordLoginDisabled": "用户名/密码登录当前已禁用",
"weakPassword": "密码强度太弱",
"usernameExists": "用户名已存在",
"emailExists": "邮箱已存在",
@@ -1228,7 +1366,10 @@
"authMethod": "认证方式",
"local": "本地",
"external": "外部 (OIDC)",
"selectPreferredLanguage": "选择您的界面首选语言"
"selectPreferredLanguage": "选择您的界面首选语言",
"currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
},
"user": {
"failedToLoadVersionInfo": "加载版本信息失败"
@@ -1287,6 +1428,7 @@
"closeDeleteAccount": "关闭删除账户",
"deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。",
"deleteAccountWarningDetails": "删除您的账户将删除所有数据,包括 SSH 主机、配置和设置。此操作不可逆。",
"deleteAccountWarningShort": "此操作不可逆,将永久删除您的帐户。",
"cannotDeleteAccount": "无法删除账户",
"lastAdminWarning": "您是最后一个管理员用户。您不能删除自己的账户,否则系统将没有任何管理员。请先将其他用户设为管理员,或联系系统支持。",
"confirmPassword": "确认密码",
@@ -1337,5 +1479,38 @@
"mobileAppInProgressDesc": "我们正在开发专门的移动应用,为移动设备提供更好的体验。",
"viewMobileAppDocs": "安装移动应用",
"mobileAppDocumentation": "移动应用文档"
},
"dashboard": {
"title": "仪表板",
"github": "GitHub",
"support": "支持",
"discord": "Discord",
"donate": "捐赠",
"serverOverview": "服务器概览",
"version": "版本",
"upToDate": "已是最新",
"updateAvailable": "有可用更新",
"uptime": "运行时间",
"database": "数据库",
"healthy": "健康",
"error": "错误",
"totalServers": "服务器总数",
"totalTunnels": "隧道总数",
"totalCredentials": "凭据总数",
"recentActivity": "最近活动",
"reset": "重置",
"loadingRecentActivity": "正在加载最近活动...",
"noRecentActivity": "无最近活动",
"quickActions": "快速操作",
"addHost": "添加主机",
"addCredential": "添加凭据",
"adminSettings": "管理员设置",
"userProfile": "用户资料",
"serverStats": "服务器统计",
"loadingServerStats": "正在加载服务器统计...",
"noServerData": "无可用服务器数据",
"cpu": "CPU",
"ram": "内存",
"notAvailable": "不可用"
}
}

View File

@@ -1,15 +1,16 @@
/* eslint-disable react-refresh/only-export-components */
import { StrictMode, useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
import DesktopApp from "@/ui/desktop/DesktopApp.tsx";
import { MobileApp } from "@/ui/mobile/MobileApp.tsx";
import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const lastSwitchTime = useRef(0);
const isCurrentlyMobile = useRef(window.innerWidth < 768);
const hasSwitchedOnce = useRef(false);
@@ -36,7 +37,6 @@ function useWindowWidth() {
isCurrentlyMobile.current = newIsMobile;
hasSwitchedOnce.current = true;
setWidth(newWidth);
setIsMobile(newIsMobile);
} else {
setWidth(newWidth);
}
@@ -56,11 +56,55 @@ function useWindowWidth() {
function RootApp() {
const width = useWindowWidth();
const isMobile = width < 768;
if (isElectron()) {
return <DesktopApp />;
}
const [showVersionCheck, setShowVersionCheck] = useState(true);
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || "";
const isTermixMobile = /Termix-Mobile/.test(userAgent);
const renderApp = () => {
if (isElectron()) {
return <DesktopApp />;
}
if (isTermixMobile) {
return <MobileApp key="mobile" />;
}
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
};
return (
<>
<div
className="fixed inset-0 pointer-events-none"
style={{
backgroundColor: "#09090b",
backgroundImage: `linear-gradient(
135deg,
transparent 0%,
transparent 49%,
rgba(255, 255, 255, 0.03) 49%,
rgba(255, 255, 255, 0.03) 51%,
transparent 51%,
transparent 100%
)`,
backgroundSize: "80px 80px",
zIndex: 0,
}}
/>
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck ? (
<ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false}
/>
) : (
renderApp()
)}
</div>
</>
);
}
createRoot(document.getElementById("root")!).render(

View File

@@ -1,22 +1,49 @@
interface ServerConfig {
serverUrl?: string;
[key: string]: unknown;
}
interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
getServerConfig: () => Promise<any>;
saveServerConfig: (config: any) => Promise<any>;
testServerConnection: (serverUrl: string) => Promise<any>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
showSaveDialog: (options: any) => Promise<any>;
showOpenDialog: (options: any) => Promise<any>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
onUpdateAvailable: (callback: Function) => void;
onUpdateDownloaded: (callback: Function) => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: any[]) => Promise<any>;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;

View File

@@ -1,9 +1,5 @@
// ============================================================================
// CENTRAL TYPE DEFINITIONS
// ============================================================================
// This file contains all shared interfaces and types used across the application
import type { Client } from "ssh2";
import type { Request } from "express";
// ============================================================================
// SSH HOST TYPES
@@ -18,11 +14,12 @@ export interface SSHHost {
folder: string;
tags: string[];
pin: boolean;
authType: "password" | "key" | "credential";
authType: "password" | "key" | "credential" | "none";
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
forceKeyboardInteractive?: boolean;
autostartPassword?: string;
autostartKey?: string;
@@ -35,6 +32,8 @@ export interface SSHHost {
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
statsConfig?: string;
terminalConfig?: TerminalConfig;
createdAt: string;
updatedAt: string;
}
@@ -47,7 +46,7 @@ export interface SSHHostData {
folder?: string;
tags?: string[];
pin?: boolean;
authType: "password" | "key" | "credential";
authType: "password" | "key" | "credential" | "none";
password?: string;
key?: File | null;
keyPassword?: string;
@@ -57,7 +56,10 @@ export interface SSHHostData {
enableTunnel?: boolean;
enableFileManager?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[];
statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig;
}
// ============================================================================
@@ -106,7 +108,6 @@ export interface TunnelConnection {
endpointPort: number;
endpointHost: string;
// Endpoint host credentials for tunnel authentication
endpointPassword?: string;
endpointKey?: string;
endpointKeyPassword?: string;
@@ -246,6 +247,34 @@ export interface TermixAlert {
actionText?: string;
}
// ============================================================================
// TERMINAL CONFIGURATION TYPES
// ============================================================================
export interface TerminalConfig {
cursorBlink: boolean;
cursorStyle: "block" | "underline" | "bar";
fontSize: number;
fontFamily: string;
letterSpacing: number;
lineHeight: number;
theme: string;
scrollback: number;
bellStyle: "none" | "sound" | "visual" | "both";
rightClickSelectsWord: boolean;
fastScrollModifier: "alt" | "ctrl" | "shift";
fastScrollSensitivity: number;
minimumContrastRatio: number;
backspaceMode: "normal" | "control-h";
agentForwarding: boolean;
environmentVariables: Array<{ key: string; value: string }>;
startupSnippetId: number | null;
autoMosh: boolean;
moshCommand: string;
}
// ============================================================================
// TAB TYPES
// ============================================================================
@@ -261,8 +290,9 @@ export interface TabContextTab {
| "file_manager"
| "user_profile";
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
hostConfig?: SSHHost;
terminalRef?: any;
initialTab?: string;
}
// ============================================================================
@@ -295,7 +325,7 @@ export type ErrorType =
// AUTHENTICATION TYPES
// ============================================================================
export type AuthType = "password" | "key" | "credential";
export type AuthType = "password" | "key" | "credential" | "none";
export type KeyType = "rsa" | "ecdsa" | "ed25519";
@@ -303,7 +333,7 @@ export type KeyType = "rsa" | "ecdsa" | "ed25519";
// API RESPONSE TYPES
// ============================================================================
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
data?: T;
error?: string;
message?: string;
@@ -337,6 +367,8 @@ export interface CredentialSelectorProps {
export interface HostManagerProps {
onSelectView?: (view: string) => void;
isTopbarOpen?: boolean;
initialTab?: string;
hostConfig?: SSHHost;
}
export interface SSHManagerHostEditorProps {
@@ -366,13 +398,13 @@ export interface SSHTunnelViewerProps {
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>
) => Promise<void>
>;
onTunnelAction?: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
) => Promise<void>;
}
export interface FileManagerProps {
@@ -400,7 +432,7 @@ export interface SSHTunnelObjectProps {
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
) => Promise<void>;
compact?: boolean;
bare?: boolean;
}
@@ -413,6 +445,26 @@ export interface FolderStats {
}>;
}
// ============================================================================
// SNIPPETS TYPES
// ============================================================================
export interface Snippet {
id: number;
userId: string;
name: string;
content: string;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface SnippetData {
name: string;
content: string;
description?: string;
}
// ============================================================================
// BACKEND TYPES
// ============================================================================
@@ -439,3 +491,95 @@ export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
// ============================================================================
// EXPRESS REQUEST TYPES
// ============================================================================
export interface AuthenticatedRequest extends Request {
userId: string;
user?: {
id: string;
username: string;
isAdmin: boolean;
};
}
// ============================================================================
// GITHUB API TYPES
// ============================================================================
export interface GitHubAsset {
id: number;
name: string;
size: number;
download_count: number;
browser_download_url: string;
}
export interface GitHubRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
assets: GitHubAsset[];
prerelease: boolean;
draft: boolean;
}
export interface GitHubAPIResponse<T> {
data: T;
cached: boolean;
cache_age?: number;
timestamp?: number;
}
// ============================================================================
// CACHE TYPES
// ============================================================================
export interface CacheEntry<T = unknown> {
data: T;
timestamp: number;
expiresAt: number;
}
// ============================================================================
// DATABASE EXPORT/IMPORT TYPES
// ============================================================================
export interface ExportSummary {
sshHostsImported: number;
sshCredentialsImported: number;
fileManagerItemsImported: number;
dismissedAlertsImported: number;
credentialUsageImported: number;
settingsImported: number;
skippedItems: number;
errors: string[];
}
export interface ImportResult {
success: boolean;
summary: ExportSummary;
}
export interface ExportRequestBody {
password: string;
}
export interface ImportRequestBody {
password: string;
}
export interface ExportPreviewBody {
scope?: string;
includeCredentials?: boolean;
}
export interface RestoreRequestBody {
backupPath: string;
targetPath?: string;
}

Some files were not shown because too many files have changed in this diff Show More