v1.7.0 #318

Merged
LukeGus merged 138 commits from dev-1.7.0 into main 2025-10-01 20:40:10 +00:00
115 changed files with 86571 additions and 10737 deletions

129
.dockerignore Normal file
View File

@@ -0,0 +1,129 @@
# 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?
._*
.Spotlight-V100
.Trashes
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

2
.env
View File

@@ -1,2 +0,0 @@
VERSION=1.6.0
VITE_API_HOST=localhost

View File

@@ -18,15 +18,14 @@ body:
label: Platform
description: How are you using Termix?
options:
- Firefox
- Safari
- Chrome
- Other Browser
- Windows
- Linux
- iOS
- Android
- Other
- Website - Firefox
- Website - Safari
- Website - Chrome
- Website - Other Browser
- App - Windows
- App - Linux
- App - iOS
- App - Android
validations:
required: true
- type: dropdown
@@ -44,7 +43,7 @@ body:
attributes:
label: Version
description: Find your version in the User Profile tab
placeholder: "e.g., 1.6.0"
placeholder: "e.g., 1.7.0"
validations:
required: true
- type: checkboxes
@@ -72,7 +71,7 @@ body:
placeholder: |
1.
2.
3.
3.
validations:
required: true
- type: textarea

View File

@@ -77,7 +77,7 @@ jobs:
run: |
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
@@ -88,7 +88,7 @@ jobs:
IMAGE_TAG="${{ github.ref_name }}"
fi
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Determine registry and image name
if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then
echo "REGISTRY=docker.io" >> $GITHUB_ENV

View File

@@ -1,13 +1,6 @@
name: Build Electron App
on:
push:
branches:
- development
paths-ignore:
- '**.md'
- '.gitignore'
- 'docker/**'
workflow_dispatch:
inputs:
build_type:
@@ -34,8 +27,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
@@ -77,8 +70,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ dist-ssr
/db/
/release/
/.claude/
/ssl/
.env

View File

@@ -9,13 +9,13 @@
## Installation
1. Clone the repository:
```sh
git clone https://github.com/LukeGus/Termix
```
```sh
git clone https://github.com/LukeGus/Termix
```
2. Install the dependencies:
```sh
npm install
```
```sh
npm install
```
## Running the development server
@@ -33,18 +33,18 @@ This will start the backend and the frontend Vite server. You can access Termix
1. **Fork the repository**: Click the "Fork" button at the top right of
the [repository page](https://github.com/LukeGus/Termix).
2. **Create a new branch**:
```sh
git checkout -b feature/my-new-feature
```
```sh
git checkout -b feature/my-new-feature
```
3. **Make your changes**: Implement your feature, fix, or improvement.
4. **Commit your changes**:
```sh
git commit -m "Feature request my new feature"
```
```sh
git commit -m "Feature request my new feature"
```
5. **Push to your fork**:
```sh
git push origin feature/my-feature-request
```
```sh
git push origin feature/my-feature-request
```
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
## Guidelines
@@ -61,7 +61,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Background Colors
| CSS Variable | Color Value | Usage | Description |
|-------------------------------|-------------|-----------------------------|------------------------------------------|
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
@@ -73,7 +73,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|--------------------|-----------------------------------------------|
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
@@ -82,7 +82,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Border Colors
| CSS Variable | Color Value | Usage | Description |
|------------------------------|-------------|-----------------|------------------------------------------|
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
@@ -93,7 +93,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Interactive States
| CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|-------------------|-----------------------------------------------|
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |

View File

@@ -45,30 +45,39 @@ If you would like, you can support the project here!\
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, and remote file editing, with many more tools to come.
access, SSH tunneling capabilities, remote file management, with many more tools to come.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (
uploading, removing, renaming, deleting files)
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
- **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
- **Modern UI** - Clean desktop/mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
- **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
- **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 and Chinese
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
mobile app also planned.
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android (coming in a few days)
# Planned Features
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
See [Projects](https://github.com/users/LukeGus/projects/3) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md).
# Installation
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view
Supported Devices:
- Website (any modern browser like Google, Safari, and Firefox)
- Windows (app)
- Linux (app)
- iOS (coming in a few days)
- Android (coming in a few days)
- iPadOS and macOS are in progress
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:
```yaml
@@ -89,10 +98,6 @@ volumes:
driver: local
```
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
built with Electron). See [Docs](https://docs.termix.site/install#pre-built-binaries) for details. A native iOS/Android app
is planned.
# Support
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
@@ -107,13 +112,17 @@ repo.
</p>
<p align="center">
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
<img src="./repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
<img src="./repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
<img src="./repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
<img src="./repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
Your browser does not support the video tag.
</video>
</p>

View File

@@ -1,8 +1,8 @@
# Stage 1: Install dependencies and build frontend
FROM node:24-alpine AS deps
# Stage 1: Install dependencies
FROM node:22-slim AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
@@ -10,7 +10,8 @@ ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm ci --force --ignore-scripts && \
RUN rm -rf node_modules package-lock.json && \
npm install --force && \
npm cache clean --force
# Stage 2: Build frontend
@@ -19,9 +20,12 @@ WORKDIR /app
COPY . .
RUN npm run build
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
# Stage 3: Build backend TypeScript
RUN npm cache clean --force && \
npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
WORKDIR /app
@@ -35,10 +39,12 @@ RUN npm rebuild better-sqlite3 --force
RUN npm run build:backend
# Stage 4: Production dependencies
FROM node:24-alpine AS production-deps
# Stage 4: Production dependencies only
FROM node:22-slim AS production-deps
WORKDIR /app
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
@@ -46,53 +52,38 @@ ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
# Stage 5: Build native modules
FROM node:24-alpine AS native-builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
# Install native modules and compile them properly
RUN npm ci --only=production --force && \
npm rebuild better-sqlite3 bcryptjs --force && \
npm cache clean --force
# Stage 6: Final image
FROM node:24-alpine
# Stage 5: Final optimized image
FROM node:22-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
RUN apk add --no-cache nginx gettext su-exec && \
mkdir -p /app/data && \
chown -R node:node /app/data
RUN apt-get update && apt-get install -y nginx gettext-base openssl && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads && \
chown -R node:node /app/data /app/uploads && \
useradd -r -s /bin/false nginx
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
RUN chown -R nginx:nginx /usr/share/nginx/html
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
WORKDIR /app
COPY --chown=nginx:nginx --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --chown=nginx:nginx --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
COPY --chown=nginx:nginx --from=frontend-builder /app/public/fonts /usr/share/nginx/html/fonts
COPY --from=native-builder /app/node_modules /app/node_modules
COPY --from=backend-builder /app/dist/backend ./dist/backend
COPY package.json ./
COPY .env ./.env
RUN chown -R node:node /app
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
COPY --chown=node:node package.json ./
VOLUME ["/app/data"]
EXPOSE ${PORT} 8081 8082 8083 8084 8085
EXPOSE ${PORT} 30001 30002 30003 30004 30005
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
CMD ["/entrypoint.sh"]

View File

@@ -1,15 +0,0 @@
services:
termix:
image: ghcr.io/lukegus/termix:latest
container_name: termix
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- termix-data:/app/data
environment:
PORT: "8080"
volumes:
termix-data:
driver: local

View File

@@ -2,14 +2,95 @@
set -e
export PORT=${PORT:-8080}
export ENABLE_SSL=${ENABLE_SSL:-false}
export SSL_PORT=${SSL_PORT:-8443}
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
echo "Configuring web UI to run on port: $PORT"
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
if [ "$ENABLE_SSL" = "true" ]; then
echo "SSL enabled - using HTTPS configuration with redirect"
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
else
echo "SSL disabled - using HTTP-only configuration (default)"
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
fi
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
mkdir -p /app/data
chown -R node:node /app/data
chmod 755 /app/data
mkdir -p /app/data /app/uploads
chown -R node:node /app/data /app/uploads
chmod 755 /app/data /app/uploads
if [ "$ENABLE_SSL" = "true" ]; then
echo "Checking SSL certificate configuration..."
mkdir -p /app/data/ssl
chown -R node:node /app/data/ssl
chmod 755 /app/data/ssl
DOMAIN=${SSL_DOMAIN:-localhost}
if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then
echo "SSL certificates found, checking validity..."
if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then
echo "SSL certificates are valid and will be reused for domain: $DOMAIN"
else
echo "SSL certificate is expired or expiring soon, regenerating..."
rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key
fi
else
echo "SSL certificates not found, will generate new ones..."
fi
if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then
echo "Generating SSL certificates for domain: $DOMAIN"
cat > /app/data/ssl/openssl.conf << EOF
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=$DOMAIN
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = $DOMAIN
DNS.2 = localhost
DNS.3 = 127.0.0.1
IP.1 = 127.0.0.1
IP.2 = ::1
IP.3 = 0.0.0.0
EOF
openssl genrsa -out /app/data/ssl/termix.key 2048
openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req
chmod 600 /app/data/ssl/termix.key
chmod 644 /app/data/ssl/termix.crt
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
rm -f /app/data/ssl/openssl.conf
echo "SSL certificates generated successfully for domain: $DOMAIN"
fi
fi
echo "Starting nginx..."
nginx
@@ -18,6 +99,17 @@ echo "Starting backend services..."
cd /app
export NODE_ENV=production
if [ -f "package.json" ]; then
VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
if [ -n "$VERSION" ]; then
export VERSION
else
echo "Warning: Could not extract version from package.json"
fi
else
echo "Warning: package.json not found"
fi
if command -v su-exec > /dev/null 2>&1; then
su-exec node node dist/backend/backend/starter.js
else

266
docker/nginx-https.conf Normal file
View File

@@ -0,0 +1,266 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_header_timeout 300s;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
server {
listen ${PORT};
server_name _;
return 301 https://$host:${SSL_PORT}$request_uri;
}
# HTTPS Server
server {
listen ${SSL_PORT} ssl;
server_name _;
ssl_certificate ${SSL_CERT_PATH};
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;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
log_not_found off;
}
location ~ ^/users(/.*)?$ {
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 ~ ^/version(/.*)?$ {
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 ~ ^/releases(/.*)?$ {
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 ~ ^/alerts(/.*)?$ {
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 ~ ^/credentials(/.*)?$ {
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;
}
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;
}
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;
}
location ~ ^/encryption(/.*)?$ {
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 /ssh/ {
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 /ssh/websocket/ {
proxy_pass http://127.0.0.1:30002/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 10s;
proxy_buffering off;
proxy_request_buffering off;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003;
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 /ssh/file_manager/recent {
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 /ssh/file_manager/pinned {
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 /ssh/file_manager/shortcuts {
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 /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;
}
location /health {
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 ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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 ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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

@@ -8,18 +8,36 @@ http {
sendfile on;
keepalive_timeout 65;
client_header_timeout 300s;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
server {
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 / {
root /usr/share/nginx/html;
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
log_not_found off;
}
location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
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;
@@ -28,7 +46,7 @@ http {
}
location ~ ^/version(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
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;
@@ -37,7 +55,7 @@ http {
}
location ~ ^/releases(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
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;
@@ -46,7 +64,7 @@ http {
}
location ~ ^/alerts(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
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;
@@ -55,7 +73,58 @@ http {
}
location ~ ^/credentials(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
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;
}
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;
}
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;
}
location ~ ^/encryption(/.*)?$ {
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;
@@ -64,7 +133,7 @@ http {
}
location /ssh/ {
proxy_pass http://127.0.0.1:8081;
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;
@@ -73,28 +142,30 @@ http {
}
location /ssh/websocket/ {
proxy_pass http://127.0.0.1:8082/;
proxy_pass http://127.0.0.1:30002/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 75s;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
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_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 10s;
proxy_buffering off;
proxy_request_buffering off;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:8083;
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -103,7 +174,7 @@ http {
}
location /ssh/file_manager/recent {
proxy_pass http://127.0.0.1:8081;
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;
@@ -112,7 +183,7 @@ http {
}
location /ssh/file_manager/pinned {
proxy_pass http://127.0.0.1:8081;
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;
@@ -121,7 +192,7 @@ http {
}
location /ssh/file_manager/shortcuts {
proxy_pass http://127.0.0.1:8081;
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;
@@ -130,16 +201,26 @@ http {
}
location /ssh/file_manager/ssh/ {
proxy_pass http://127.0.0.1:8084;
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;
}
location /health {
proxy_pass http://127.0.0.1:8081;
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;
@@ -148,7 +229,7 @@ http {
}
location ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:8085;
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -157,7 +238,7 @@ http {
}
location ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:8085;
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -1,6 +1,12 @@
const { app, BrowserWindow, shell, ipcMain } = require("electron");
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
const path = require("path");
const fs = require("fs");
const os = require("os");
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");
gemini-code-assist[bot] commented 2025-10-01 20:38:41 +00:00 (Migrated from github.com)
Review

medium

Globally ignoring all certificate and SSL errors is a significant security risk. This makes the application vulnerable to Man-in-the-Middle (MITM) attacks, as it will trust any certificate, including malicious ones. While this might be intended for connecting to self-hosted instances with self-signed certificates, it should not be enabled by default for all connections. Consider making this a user-configurable setting that is disabled by default, and perhaps apply it more granularly only to user-defined server connections rather than globally.

![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) Globally ignoring all certificate and SSL errors is a significant security risk. This makes the application vulnerable to Man-in-the-Middle (MITM) attacks, as it will trust any certificate, including malicious ones. While this might be intended for connecting to self-hosted instances with self-signed certificates, it should not be enabled by default for all connections. Consider making this a user-configurable setting that is disabled by default, and perhaps apply it more granularly only to user-defined server connections rather than globally.
let mainWindow = null;
@@ -13,7 +19,6 @@ if (!gotTheLock) {
process.exit(0);
} else {
app.on("second-instance", (event, commandLine, workingDirectory) => {
console.log("Second instance detected, focusing existing window...");
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
@@ -35,7 +40,7 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: !isDev,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
},
show: false,
@@ -50,12 +55,10 @@ function createWindow() {
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, "..", "dist", "index.html");
console.log("Loading frontend from:", indexPath);
mainWindow.loadFile(indexPath);
}
mainWindow.once("ready-to-show", () => {
console.log("Window ready to show");
mainWindow.show();
});
@@ -96,6 +99,163 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "LukeGus";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
async function fetchGitHubAPI(endpoint, cacheKey) {
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
let fetch;
try {
fetch = globalThis.fetch || require("node-fetch");
} catch (e) {
const https = require("https");
const http = require("http");
const { URL } = require("url");
fetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const requestOptions = {
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
};
if (isHttps) {
requestOptions.rejectUnauthorized = false;
requestOptions.agent = new https.Agent({
rejectUnauthorized: false,
secureProtocol: "TLSv1_2_method",
checkServerIdentity: () => undefined,
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
honorCipherOrder: true,
});
}
const req = client.request(url, requestOptions, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
};
}
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
gemini-code-assist[bot] commented 2025-10-01 20:38:40 +00:00 (Migrated from github.com)
Review

critical

Disabling certificate validation (rejectUnauthorized: false) for requests to the GitHub API is a critical security vulnerability. The GitHub API uses a valid, trusted SSL certificate, so there is no need to bypass this check. This code exposes users to Man-in-the-Middle (MITM) attacks when checking for application updates, where an attacker could intercept the request and provide a malicious update package. The custom fetch implementation and its security-disabling options should be removed for API calls to trusted services like GitHub.

![critical](https://www.gstatic.com/codereviewagent/critical.svg) Disabling certificate validation (`rejectUnauthorized: false`) for requests to the GitHub API is a critical security vulnerability. The GitHub API uses a valid, trusted SSL certificate, so there is no need to bypass this check. This code exposes users to Man-in-the-Middle (MITM) attacks when checking for application updates, where an attacker could intercept the request and provide a malicious update package. The custom `fetch` implementation and its security-disabling options should be removed for API calls to trusted services like GitHub.
const data = await response.json();
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron",
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const isUpToDate = localVersion === remoteVersion;
const result = {
success: true,
status: isUpToDate ? "up_to_date" : "requires_update",
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
return result;
} catch (error) {
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
@@ -135,54 +295,58 @@ ipcMain.handle("save-server-config", (event, config) => {
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
let fetch;
try {
fetch = globalThis.fetch || require("node:fetch");
} catch (e) {
const https = require("https");
const http = require("http");
const { URL } = require("url");
const https = require("https");
const http = require("http");
const { URL } = require("url");
fetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const fetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const req = client.request(
url,
{
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 5000,
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
},
);
const requestOptions = {
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
};
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
if (isHttps) {
requestOptions.rejectUnauthorized = false;
requestOptions.agent = new https.Agent({
rejectUnauthorized: false,
secureProtocol: "TLSv1_2_method",
checkServerIdentity: () => undefined,
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
honorCipherOrder: true,
});
}
if (options.body) {
req.write(options.body);
}
req.end();
const req = client.request(url, requestOptions, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
});
};
}
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
};
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
@@ -191,7 +355,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
const response = await fetch(healthUrl, {
method: "GET",
timeout: 5000,
timeout: 10000,
});
if (response.ok) {
@@ -203,9 +367,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Health endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
@@ -240,7 +401,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, {
method: "GET",
timeout: 5000,
timeout: 10000,
});
if (response.ok) {
@@ -252,9 +413,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Version endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
@@ -300,7 +458,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
app.whenReady().then(() => {
createWindow();
console.log("Termix started successfully");
});
app.on("window-all-closed", () => {
@@ -317,10 +474,6 @@ app.on("activate", () => {
}
});
app.on("before-quit", () => {
console.log("App is quitting...");
});
app.on("will-quit", () => {
console.log("App will quit...");
});

View File

@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) =>
@@ -25,5 +26,3 @@ contextBridge.exposeInMainWorld("electronAPI", {
});
window.IS_ELECTRON = true;
console.log("electronAPI exposed to window");

9533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
"version": "1.6.0",
"version": "1.7.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -12,20 +12,24 @@
"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",
"lint": "eslint .",
"preview": "vite preview",
"electron": "electron .",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && 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-portable": "npm run build && electron-builder --linux --dir",
"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"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.3.3",
"@codemirror/search": "^6.5.11",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.23.1",
"@hookform/resolvers": "^5.1.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -34,30 +38,28 @@
"@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.5",
"@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",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/codemirror-themes": "^4.24.1",
"@uiw/react-codemirror": "^4.24.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.2.0",
"body-parser": "^1.20.2",
"chalk": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -68,9 +70,9 @@
"express": "^5.1.0",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jose": "^5.2.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.525.0",
"multer": "^2.0.2",
"nanoid": "^5.1.5",
@@ -79,17 +81,24 @@
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.7.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-player": "^3.3.3",
"react-resizable-panels": "^3.0.3",
"react-simple-keyboard": "^3.8.120",
"react-syntax-highlighter": "^15.6.6",
"react-xtermjs": "^1.0.10",
"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.11",
"validator": "^13.15.15",
"wait-on": "^9.0.1",
"ws": "^8.18.3",
"zod": "^4.0.5"
},
@@ -105,22 +114,16 @@
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"electron": "^38.0.0",
"electron-builder": "^26.0.12",
"electron-icon-builder": "^2.0.1",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.5",
"wait-on": "^8.0.4"
"vite": "^7.1.5"
}
}

58128
public/pdf.worker.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 305 KiB

BIN
repo-images/Image 6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

103
scripts/enable-ssl.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
log_info() {
echo -e "${BLUE}[SSL Setup]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SSL Setup]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[SSL Setup]${NC} $1"
}
log_error() {
echo -e "${RED}[SSL Setup]${NC} $1"
}
log_header() {
echo -e "${CYAN}$1${NC}"
}
generate_keys() {
log_info "Generating security keys..."
JWT_SECRET=$(openssl rand -hex 32)
log_success "Generated JWT secret"
DATABASE_KEY=$(openssl rand -hex 32)
log_success "Generated database encryption key"
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE"
log_success "Security keys added to .env file"
}
setup_env_file() {
log_info "Setting up environment configuration..."
if [[ -f "$ENV_FILE" ]]; then
log_warn ".env file already exists, creating backup..."
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
fi
cat > "$ENV_FILE" << EOF
# Termix SSL Configuration - Auto-generated $(date)
# SSL/TLS Configuration
ENABLE_SSL=true
SSL_PORT=8443
SSL_DOMAIN=localhost
PORT=8080
# Node environment
NODE_ENV=production
# CORS configuration
ALLOWED_ORIGINS=*
EOF
generate_keys
log_success "Environment configuration created at $ENV_FILE"
}
setup_ssl_certificates() {
log_info "Setting up SSL certificates..."
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
bash "$SCRIPT_DIR/setup-ssl.sh"
else
log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
exit 1
fi
}
main() {
if ! command -v openssl &> /dev/null; then
log_error "OpenSSL is not installed. Please install OpenSSL first."
exit 1
fi
setup_env_file
setup_ssl_certificates
}
# Run main function
main "$@"

121
scripts/setup-ssl.sh Normal file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
set -e
SSL_DIR="$(dirname "$0")/../ssl"
CERT_FILE="$SSL_DIR/termix.crt"
KEY_FILE="$SSL_DIR/termix.key"
DAYS_VALID=365
DOMAIN=${SSL_DOMAIN:-"localhost"}
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}[SSL Setup]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SSL Setup]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[SSL Setup]${NC} $1"
}
log_error() {
echo -e "${RED}[SSL Setup]${NC} $1"
}
check_existing_cert() {
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
log_success "Valid SSL certificate already exists"
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
log_info "Expires: $expiry"
return 0
else
log_warn "Existing certificate is expired or expiring soon"
fi
fi
return 1
}
generate_certificate() {
log_info "Generating new SSL certificate for domain: $DOMAIN"
mkdir -p "$SSL_DIR"
local config_file="$SSL_DIR/openssl.conf"
cat > "$config_file" << EOF
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=$DOMAIN
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
DNS.3 = *.localhost
IP.1 = 127.0.0.1
EOF
if [[ -n "$SSL_ALT_NAMES" ]]; then
local counter=2
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
for name in "${NAMES[@]}"; do
name=$(echo "$name" | xargs)
if [[ "$name" == DNS:* ]]; then
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
elif [[ "$name" == IP:* ]]; then
echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file"
fi
done
fi
log_info "Generating private key..."
openssl genrsa -out "$KEY_FILE" 2048
log_info "Generating certificate..."
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
chmod 600 "$KEY_FILE"
chmod 644 "$CERT_FILE"
rm -f "$config_file"
log_success "SSL certificate generated successfully"
log_info "Valid for: $DAYS_VALID days"
}
main() {
if ! command -v openssl &> /dev/null; then
log_error "OpenSSL is not installed. Please install OpenSSL first."
exit 1
fi
generate_certificate
}
main "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@ import * as schema from "./schema.js";
import fs from "fs";
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseMigration } from "../../utils/database-migration.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir);
@@ -15,29 +19,125 @@ if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
const dbPath = path.join(dataDir, "db.sqlite");
databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: dbPath,
});
const sqlite = new Database(dbPath);
const encryptedDbPath = `${dbPath}.encrypted`;
sqlite.exec(`
let actualDbPath = ":memory:";
let memoryDatabase: Database.Database;
let isNewDatabase = false;
let sqlite: Database.Database;
async function initializeDatabaseAsync(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance();
const dbKey = await systemCrypto.getDatabaseKey();
if (enableFileEncryption) {
try {
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
const decryptedBuffer =
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
memoryDatabase = new Database(decryptedBuffer);
} else {
const migration = new DatabaseMigration(dataDir);
const migrationStatus = migration.checkMigrationStatus();
if (migrationStatus.needsMigration) {
const migrationResult = await migration.migrateDatabase();
if (migrationResult.success) {
migration.cleanupOldBackups();
if (
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
) {
const decryptedBuffer =
await DatabaseFileEncryption.decryptDatabaseToBuffer(
encryptedDbPath,
);
memoryDatabase = new Database(decryptedBuffer);
isNewDatabase = false;
} else {
throw new Error(
"Migration completed but encrypted database file not found",
);
}
} else {
databaseLogger.error("Automatic database migration failed", null, {
operation: "auto_migration_failed",
error: migrationResult.error,
migratedTables: migrationResult.migratedTables,
migratedRows: migrationResult.migratedRows,
duration: migrationResult.duration,
backupPath: migrationResult.backupPath,
});
throw new Error(
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
);
}
} else {
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
}
}
} catch (error) {
databaseLogger.error("Failed to initialize memory database", error, {
operation: "db_memory_init_failed",
errorMessage: error instanceof Error ? error.message : "Unknown error",
errorStack: error instanceof Error ? error.stack : undefined,
encryptedDbExists:
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
databaseKeyAvailable: !!process.env.DATABASE_KEY,
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
});
throw new Error(
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
);
}
} else {
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
}
}
async function initializeCompleteDatabase(): Promise<void> {
await initializeDatabaseAsync();
databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: actualDbPath,
encrypted:
enableFileEncryption &&
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
inMemory: true,
isNewDatabase,
});
sqlite = memoryDatabase;
db = drizzle(sqlite, { schema });
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile',
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE IF NOT EXISTS settings (
@@ -141,8 +241,30 @@ sqlite.exec(`
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
migrateSchema();
try {
const row = sqlite
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (!row) {
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
}
} catch (e) {
databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
error: e,
});
}
}
const addColumnIfNotExists = (
table: string,
column: string,
@@ -157,18 +279,8 @@ const addColumnIfNotExists = (
.get();
} catch (e) {
try {
databaseLogger.debug(`Adding column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
});
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
databaseLogger.success(`Column ${column} added to ${table}`, {
operation: "schema_migration",
table,
column,
});
} catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
operation: "schema_migration",
@@ -181,10 +293,6 @@ const addColumnIfNotExists = (
};
const migrateSchema = () => {
databaseLogger.info("Checking for schema updates...", {
operation: "schema_migration",
});
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
@@ -250,6 +358,14 @@ const migrateSchema = () => {
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
@@ -259,48 +375,170 @@ const migrateSchema = () => {
});
};
const initializeDatabase = async () => {
migrateSchema();
async function saveMemoryDatabaseToFile() {
if (!memoryDatabase) return;
try {
const row = sqlite
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (!row) {
databaseLogger.info("Initializing default settings", {
operation: "db_init",
setting: "allow_registration",
});
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
databaseLogger.success("Default settings initialized", {
operation: "db_init",
});
} else {
databaseLogger.debug("Default settings already exist", {
operation: "db_init",
});
const buffer = memoryDatabase.serialize();
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
} catch (e) {
databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
error: e,
if (enableFileEncryption) {
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
encryptedDbPath,
);
} else {
fs.writeFileSync(dbPath, buffer);
}
} catch (error) {
databaseLogger.error("Failed to save in-memory database", error, {
operation: "memory_db_save_failed",
enableFileEncryption,
});
}
}
async function handlePostInitFileEncryption() {
if (!enableFileEncryption) return;
try {
if (memoryDatabase) {
await saveMemoryDatabaseToFile();
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
}
try {
const migration = new DatabaseMigration(dataDir);
migration.cleanupOldBackups();
} catch (cleanupError) {
databaseLogger.warn("Failed to cleanup old migration files", {
operation: "migration_cleanup_startup_failed",
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
});
}
} catch (error) {
databaseLogger.error(
"Failed to handle database file encryption setup",
error,
{
operation: "db_encrypt_setup_failed",
},
);
}
}
async function initializeDatabase(): Promise<void> {
await initializeCompleteDatabase();
await handlePostInitFileEncryption();
}
export { initializeDatabase };
async function cleanupDatabase() {
if (memoryDatabase) {
try {
await saveMemoryDatabaseToFile();
} catch (error) {
databaseLogger.error(
"Failed to save in-memory database before shutdown",
error,
{
operation: "shutdown_save_failed",
},
);
}
}
try {
if (sqlite) {
sqlite.close();
}
} catch (error) {
databaseLogger.warn("Error closing database connection", {
operation: "db_close_error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
try {
const tempDir = path.join(dataDir, ".temp");
if (fs.existsSync(tempDir)) {
const files = fs.readdirSync(tempDir);
for (const file of files) {
try {
fs.unlinkSync(path.join(tempDir, file));
} catch {}
}
try {
fs.rmdirSync(tempDir);
} catch {}
}
} catch (error) {}
}
process.on("exit", () => {
if (sqlite) {
try {
sqlite.close();
} catch {}
}
});
process.on("SIGINT", async () => {
databaseLogger.info("Received SIGINT, cleaning up...", {
operation: "shutdown",
});
await cleanupDatabase();
process.exit(0);
});
process.on("SIGTERM", async () => {
databaseLogger.info("Received SIGTERM, cleaning up...", {
operation: "shutdown",
});
await cleanupDatabase();
process.exit(0);
});
let db: ReturnType<typeof drizzle<typeof schema>>;
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
if (!db) {
throw new Error(
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
);
}
return db;
}
export function getSqlite(): Database.Database {
if (!sqlite) {
throw new Error(
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
);
}
return sqlite;
}
export { db };
export { DatabaseFileEncryption };
export const databasePaths = {
main: actualDbPath,
encrypted: encryptedDbPath,
directory: dbDir,
inMemory: true,
};
initializeDatabase().catch((error) => {
databaseLogger.error("Failed to initialize database", error, {
operation: "db_init",
});
process.exit(1);
});
export { saveMemoryDatabaseToFile };
databaseLogger.success("Database connection established", {
operation: "db_init",
path: dbPath,
});
export const db = drizzle(sqlite, { schema });
export { DatabaseSaveTrigger };

View File

@@ -49,6 +49,10 @@ export const sshData = sqliteTable("ssh_data", {
keyPassword: text("key_password"),
keyType: text("key_type"),
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key", { length: 8192 }),
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
@@ -138,8 +142,11 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
privateKey: text("private_key", { length: 16384 }),
publicKey: text("public_key", { length: 4096 }),
keyPassword: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
createdAt: text("created_at")

View File

@@ -4,6 +4,7 @@ import { dismissedAlerts } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import fetch from "node-fetch";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
interface CacheEntry {
data: any;
@@ -107,31 +108,14 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const router = express.Router();
// Route: Get all active alerts
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
// Route: Get alerts for the authenticated user (excluding dismissed ones)
// GET /alerts
router.get("/", async (req, res) => {
router.get("/", authenticateJWT, async (req, res) => {
try {
const alerts = await fetchAlertsFromGitHub();
res.json({
alerts,
cached: alertCache.get("termix_alerts") !== null,
total_count: alerts.length,
});
} catch (error) {
authLogger.error("Failed to get alerts", error);
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
// Route: Get alerts for a specific user (excluding dismissed ones)
// GET /alerts/user/:userId
router.get("/user/:userId", async (req, res) => {
try {
const { userId } = req.params;
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
const userId = (req as any).userId;
const allAlerts = await fetchAlertsFromGitHub();
@@ -144,32 +128,31 @@ router.get("/user/:userId", async (req, res) => {
dismissedAlertRecords.map((record) => record.alertId),
);
const userAlerts = allAlerts.filter(
const activeAlertsForUser = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size,
alerts: activeAlertsForUser,
cached: alertCache.get("termix_alerts") !== null,
total_count: activeAlertsForUser.length,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch user alerts" });
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
// Route: Dismiss an alert for a user
// Route: Dismiss an alert for the authenticated user
// POST /alerts/dismiss
router.post("/dismiss", async (req, res) => {
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { userId, alertId } = req.body;
const { alertId } = req.body;
const userId = (req as any).userId;
if (!userId || !alertId) {
authLogger.warn("Missing userId or alertId in dismiss request");
return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
if (!alertId) {
authLogger.warn("Missing alertId in dismiss request", { userId });
return res.status(400).json({ error: "Alert ID is required" });
}
const existingDismissal = await db
@@ -201,13 +184,9 @@ router.post("/dismiss", async (req, res) => {
// Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId
router.get("/dismissed/:userId", async (req, res) => {
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const { userId } = req.params;
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
const userId = (req as any).userId;
const dismissedAlertRecords = await db
.select({
@@ -227,16 +206,15 @@ router.get("/dismissed/:userId", async (req, res) => {
}
});
// Route: Undismiss an alert for a user (remove from dismissed list)
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
// DELETE /alerts/dismiss
router.delete("/dismiss", async (req, res) => {
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { userId, alertId } = req.body;
const { alertId } = req.body;
const userId = (req as any).userId;
if (!userId || !alertId) {
return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
if (!alertId) {
return res.status(400).json({ error: "Alert ID is required" });
}
const result = await db

File diff suppressed because it is too large Load Diff

View File

@@ -8,20 +8,21 @@ import {
fileManagerPinned,
fileManagerShortcuts,
} from "../db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import multer from "multer";
import { sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseSaveTrigger } from "../db/index.js";
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
interface JWTPayload {
userId: string;
}
function isNonEmptyString(value: any): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -30,61 +31,148 @@ function isValidPort(port: any): port is number {
return typeof port === "number" && port > 0 && port <= 65535;
}
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
sshLogger.warn("Missing or invalid Authorization header");
return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.split(" ")[1];
const jwtSecret = process.env.JWT_SECRET || "secret";
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
sshLogger.warn("Invalid or expired token");
return res.status(401).json({ error: "Invalid or expired token" });
}
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress;
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
}
// Internal-only endpoint for autostart (no JWT)
router.get("/db/host/internal", async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers["x-internal-request"] !== "1") {
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint");
return res.status(403).json({ error: "Forbidden" });
}
try {
const data = await db.select().from(sshData);
const result = data.map((row: any) => {
return {
...row,
tags:
typeof row.tags === "string"
? row.tags
? row.tags.split(",").filter(Boolean)
: []
: [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
? JSON.parse(row.tunnelConnections)
: [],
enableFileManager: !!row.enableFileManager,
};
});
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
const autostartHosts = await db
.select()
.from(sshData)
.where(
and(
eq(sshData.enableTunnel, true),
isNotNull(sshData.tunnelConnections),
),
);
const result = autostartHosts
.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: any) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
return null;
}
return {
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: any) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
tags: ["autostart"],
};
})
.filter(Boolean);
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch SSH data (internal)", err);
res.status(500).json({ error: "Failed to fetch SSH data" });
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
const allHosts = await db.select().from(sshData);
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
keyPassword: host.autostartKeyPassword || host.keyPassword,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
@@ -93,6 +181,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
router.post(
"/db/host",
authenticateJWT,
requireDataAccess,
upload.single("key"),
async (req: Request, res: Response) => {
const userId = (req as any).userId;
@@ -192,12 +281,22 @@ router.post(
sshDataObj.keyPassword = keyPassword || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
try {
const result = await db.insert(sshData).values(sshDataObj).returning();
const result = await SimpleDBOps.insert(
sshData,
"ssh_data",
sshDataObj,
userId,
);
if (result.length === 0) {
if (!result) {
sshLogger.warn("No host returned after creation", {
operation: "host_create",
userId,
@@ -208,7 +307,7 @@ router.post(
return res.status(500).json({ error: "Failed to create host" });
}
const createdHost = result[0];
const createdHost = result;
const baseHost = {
...createdHost,
tags:
@@ -372,18 +471,33 @@ router.put(
sshDataObj.keyType = keyType;
}
sshDataObj.password = null;
} else {
// For credential auth
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
try {
await db
.update(sshData)
.set(sshDataObj)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
await SimpleDBOps.update(
sshData,
"ssh_data",
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
sshDataObj,
userId,
);
const updatedHosts = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
const updatedHosts = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
),
"ssh_data",
userId,
);
if (updatedHosts.length === 0) {
sshLogger.warn("Updated host not found after update", {
@@ -455,10 +569,11 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const data = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
"ssh_data",
userId,
);
const result = await Promise.all(
data.map(async (row: any) => {
@@ -1074,14 +1189,16 @@ router.put(
}
try {
const updatedHosts = await db
.update(sshData)
.set({
const updatedHosts = await SimpleDBOps.update(
sshData,
"ssh_data",
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
})
.where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName)))
.returning();
},
userId,
);
const updatedCredentials = await db
.update(sshCredentials)
@@ -1097,6 +1214,9 @@ router.put(
)
.returning();
// Trigger database save after folder rename
DatabaseSaveTrigger.triggerSave("folder_rename");
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
@@ -1221,7 +1341,7 @@ router.post(
updatedAt: new Date().toISOString(),
};
await db.insert(sshData).values(sshDataObj);
await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
results.success++;
} catch (error) {
results.failed++;
@@ -1240,4 +1360,248 @@ router.post(
},
);
// Route: Enable autostart for SSH configuration (requires JWT)
// POST /ssh/autostart/enable
router.post(
"/autostart/enable",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
const sshConfig = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found",
});
}
const config = sshConfig[0];
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: any) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHosts = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
const updateResult = await db
.update(sshData)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));
try {
await DatabaseSaveTrigger.triggerSave();
} catch (saveError) {
sshLogger.warn("Database save failed after autostart", {
operation: "autostart_db_save_failed",
error:
saveError instanceof Error ? saveError.message : "Unknown error",
});
}
res.json({
message: "AutoStart enabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
// Route: Disable autostart for SSH configuration (requires JWT)
// DELETE /ssh/autostart/disable
router.delete(
"/autostart/disable",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const result = await db
.update(sshData)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
res.json({
message: "AutoStart disabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
// Route: Get autostart status for user's SSH configurations (requires JWT)
// GET /ssh/autostart/status
router.get(
"/autostart/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
try {
const autostartConfigs = await db
.select()
.from(sshData)
.where(
and(
eq(sshData.userId, userId),
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey),
),
),
);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType,
}));
res.json({
autostart_configs: statusList,
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
export default router;

View File

@@ -1,4 +1,5 @@
import express from "express";
import crypto from "crypto";
import { db } from "../db/index.js";
import {
users,
@@ -7,15 +8,21 @@ import {
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
settings,
} from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import jwt from "jsonwebtoken";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import type { Request, Response, NextFunction } from "express";
import { authLogger, apiLogger } from "../../utils/logger.js";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { UserCrypto } from "../../utils/user-crypto.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
const authManager = AuthManager.getInstance();
async function verifyOIDCToken(
idToken: string,
@@ -70,12 +77,8 @@ async function verifyOIDCToken(
);
}
} else {
authLogger.error(
`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`,
);
}
} catch (error) {
authLogger.error(`JWKS fetch error from ${url}:`, error);
continue;
}
}
@@ -112,7 +115,6 @@ async function verifyOIDCToken(
return payload;
} catch (error) {
authLogger.error("OIDC token verification failed:", error);
throw error;
}
}
@@ -129,35 +131,9 @@ interface JWTPayload {
exp?: number;
}
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
authLogger.warn("Missing or invalid Authorization header", {
operation: "auth",
method: req.method,
url: req.url,
});
return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.split(" ")[1];
const jwtSecret = process.env.JWT_SECRET || "secret";
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
authLogger.warn("Invalid or expired token", {
operation: "auth",
method: req.method,
url: req.url,
error: err,
});
return res.status(401).json({ error: "Invalid or expired token" });
}
}
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Route: Create traditional user (username/password)
// POST /users/create
@@ -208,19 +184,10 @@ router.post("/create", async (req, res) => {
}
let isFirstUser = false;
try {
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
} catch (e) {
isFirstUser = true;
authLogger.warn("Failed to check user count, assuming first user", {
operation: "user_create",
username,
error: e,
});
}
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(password, saltRounds);
@@ -244,6 +211,23 @@ router.post("/create", async (req, res) => {
totp_backup_codes: null,
});
try {
await authManager.registerUser(id, password);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
authLogger.error(
"Failed to setup user encryption, user creation rolled back",
encryptionError,
{
operation: "user_create_encryption_failed",
userId: id,
},
);
return res.status(500).json({
error: "Failed to setup user security - user creation cancelled",
});
}
authLogger.success(
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
{
@@ -343,11 +327,54 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
scopes: scopes || "openid email profile",
};
let encryptedConfig;
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
const configWithId = { ...config, id: `oidc-config-${userId}` };
encryptedConfig = DataCrypto.encryptRecord(
"settings",
configWithId,
userId,
adminDataKey,
);
authLogger.info("OIDC configuration encrypted with admin data key", {
operation: "oidc_config_encrypt",
userId,
});
} else {
encryptedConfig = {
...config,
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
};
authLogger.warn(
"OIDC configuration stored with basic encoding - admin should re-save with password",
{
operation: "oidc_config_basic_encoding",
userId,
},
);
}
} catch (encryptError) {
authLogger.error(
"Failed to encrypt OIDC configuration, storing with basic encoding",
encryptError,
{
operation: "oidc_config_encrypt_failed",
userId,
},
);
encryptedConfig = {
...config,
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
};
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)",
)
.run(JSON.stringify(config));
.run(JSON.stringify(encryptedConfig));
authLogger.info("OIDC configuration updated", {
operation: "oidc_update",
userId,
@@ -383,7 +410,7 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
}
});
// Route: Get OIDC configuration
// Route: Get OIDC configuration (public - needed for login page)
// GET /users/oidc-config
router.get("/oidc-config", async (req, res) => {
try {
@@ -393,7 +420,67 @@ router.get("/oidc-config", async (req, res) => {
if (!row) {
return res.json(null);
}
res.json(JSON.parse((row as any).value));
let config = JSON.parse((row as any).value);
if (config.client_secret) {
if (config.client_secret.startsWith("encrypted:")) {
const authHeader = req.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
const authManager = AuthManager.getInstance();
const payload = await authManager.verifyJWTToken(token);
if (payload) {
const userId = payload.userId;
const user = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (user && user.length > 0 && user[0].is_admin) {
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
config = DataCrypto.decryptRecord(
"settings",
config,
userId,
adminDataKey,
);
} else {
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
}
} catch (decryptError) {
authLogger.warn("Failed to decrypt OIDC config for admin", {
operation: "oidc_config_decrypt_failed",
userId,
});
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
}
} else {
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
}
} else {
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
}
} else {
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
}
} else if (config.client_secret.startsWith("encoded:")) {
try {
const decoded = Buffer.from(
config.client_secret.substring(8),
"base64",
).toString("utf8");
config.client_secret = decoded;
} catch {
config.client_secret = "[ENCODING ERROR]";
}
}
}
res.json(config);
} catch (err) {
authLogger.error("Failed to get OIDC config", err);
res.status(500).json({ error: "Failed to get OIDC config" });
@@ -421,7 +508,7 @@ router.get("/oidc/authorize", async (req, res) => {
"http://localhost:5173";
if (origin.includes("localhost")) {
origin = "http://localhost:8081";
origin = "http://localhost:30001";
}
const redirectUri = `${origin}/users/oidc/callback`;
@@ -565,10 +652,6 @@ router.get("/oidc/callback", async (req, res) => {
config.client_id,
);
} catch (error) {
authLogger.error(
"OIDC token verification failed, trying userinfo endpoints",
error,
);
try {
const parts = tokenData.id_token.split(".");
if (parts.length === 3) {
@@ -654,14 +737,10 @@ router.get("/oidc/callback", async (req, res) => {
let isFirstUser = false;
if (!user || user.length === 0) {
try {
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
} catch (e) {
isFirstUser = true;
}
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
const id = nanoid();
await db.insert(users).values({
@@ -681,6 +760,23 @@ router.get("/oidc/callback", async (req, res) => {
scopes: config.scopes,
});
try {
await authManager.registerOIDCUser(id);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
authLogger.error(
"Failed to setup OIDC user encryption, user creation rolled back",
encryptionError,
{
operation: "oidc_user_create_encryption_failed",
userId: id,
},
);
return res.status(500).json({
error: "Failed to setup user security - user creation cancelled",
});
}
user = await db.select().from(users).where(eq(users.id, id));
} else {
await db
@@ -693,8 +789,16 @@ router.get("/oidc/callback", async (req, res) => {
const userRecord = user[0];
const jwtSecret = process.env.JWT_SECRET || "secret";
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
try {
await authManager.authenticateOIDCUser(userRecord.id);
} catch (setupError) {
authLogger.error("Failed to setup OIDC user encryption", setupError, {
operation: "oidc_user_encryption_setup_failed",
userId: userRecord.id,
});
}
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "50d",
});
@@ -706,9 +810,14 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set("success", "true");
redirectUrl.searchParams.set("token", token);
res.redirect(redirectUrl.toString());
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
)
.redirect(redirectUrl.toString());
} catch (err) {
authLogger.error("OIDC callback failed", err);
@@ -775,33 +884,101 @@ router.post("/login", async (req, res) => {
});
return res.status(401).json({ error: "Incorrect password" });
}
const jwtSecret = process.env.JWT_SECRET || "secret";
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
expiresIn: "50d",
});
try {
const kekSalt = await db
.select()
.from(settings)
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
if (kekSalt.length === 0) {
await authManager.registerUser(userRecord.id, password);
}
} catch (setupError) {
// Continue if setup fails - authenticateUser will handle it
}
const dataUnlocked = await authManager.authenticateUser(
userRecord.id,
password,
);
if (!dataUnlocked) {
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.totp_enabled) {
const tempToken = jwt.sign(
{ userId: userRecord.id, pending_totp: true },
jwtSecret,
{ expiresIn: "10m" },
);
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
expiresIn: "10m",
});
return res.json({
success: true,
requires_totp: true,
temp_token: tempToken,
});
}
return res.json({
token,
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "24h",
});
authLogger.success(`User logged in successfully: ${username}`, {
operation: "user_login_success",
username,
userId: userRecord.id,
dataUnlocked: true,
});
const response: any = {
success: true,
is_admin: !!userRecord.is_admin,
username: userRecord.username,
});
};
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
if (isElectron) {
response.token = token;
}
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
)
.json(response);
} catch (err) {
authLogger.error("Failed to log in user", err);
return res.status(500).json({ error: "Login failed" });
}
});
// Route: Logout user
// POST /users/logout
router.post("/logout", async (req, res) => {
try {
const userId = (req as any).userId;
if (userId) {
authManager.logoutUser(userId);
authLogger.info("User logged out", {
operation: "user_logout",
userId,
});
}
return res
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
.json({ success: true, message: "Logged out successfully" });
} catch (err) {
authLogger.error("Logout failed", err);
return res.status(500).json({ error: "Logout failed" });
}
});
// Route: Get current user's info using JWT
// GET /users/me
router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
@@ -816,12 +993,16 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
authLogger.warn(`User not found for /users/me: ${userId}`);
return res.status(401).json({ error: "User not found" });
}
const isDataUnlocked = authManager.isUserUnlocked(userId);
res.json({
userId: user[0].id,
username: user[0].username,
is_admin: !!user[0].is_admin,
is_oidc: !!user[0].is_oidc,
totp_enabled: !!user[0].totp_enabled,
data_unlocked: isDataUnlocked,
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -829,10 +1010,34 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
});
// Route: Count users
// GET /users/count
router.get("/count", async (req, res) => {
// Route: Check if system requires initial setup (public - for first-time setup detection)
// GET /users/setup-required
router.get("/setup-required", async (req, res) => {
try {
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
const count = (countResult as any)?.count || 0;
res.json({
setup_required: count === 0,
});
} catch (err) {
authLogger.error("Failed to check setup status", err);
res.status(500).json({ error: "Failed to check setup status" });
}
});
// Route: Count users (admin only - for dashboard statistics)
// GET /users/count
router.get("/count", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user[0] || !user[0].is_admin) {
return res.status(403).json({ error: "Admin access required" });
}
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
@@ -846,7 +1051,7 @@ router.get("/count", async (req, res) => {
// Route: DB health check (actually queries DB)
// GET /users/db-health
router.get("/db-health", async (req, res) => {
router.get("/db-health", requireAdmin, async (req, res) => {
try {
db.$client.prepare("SELECT 1").get();
res.json({ status: "ok" });
@@ -856,7 +1061,7 @@ router.get("/db-health", async (req, res) => {
}
});
// Route: Get registration allowed status
// Route: Get registration allowed status (public - needed for login page)
// GET /users/registration-allowed
router.get("/registration-allowed", async (req, res) => {
try {
@@ -977,7 +1182,7 @@ router.post("/initiate-reset", async (req, res) => {
});
}
const resetCode = Math.floor(100000 + Math.random() * 900000).toString();
const resetCode = crypto.randomInt(100000, 1000000).toString();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
db.$client
@@ -1095,6 +1300,15 @@ router.post("/complete-reset", async (req, res) => {
return res.status(400).json({ error: "Invalid temporary token" });
}
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userId = user[0].id;
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
@@ -1103,6 +1317,8 @@ router.post("/complete-reset", async (req, res) => {
.set({ password_hash })
.where(eq(users.username, username));
authLogger.success(`Password successfully reset for user: ${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`);
@@ -1110,7 +1326,6 @@ router.post("/complete-reset", async (req, res) => {
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
authLogger.success(`Password successfully reset for user: ${username}`);
res.json({ message: "Password has been successfully reset" });
} catch (err) {
authLogger.error("Failed to complete password reset", err);
@@ -1245,11 +1460,9 @@ router.post("/totp/verify-login", async (req, res) => {
return res.status(400).json({ error: "Token and TOTP code are required" });
}
const jwtSecret = process.env.JWT_SECRET || "secret";
try {
const decoded = jwt.verify(temp_token, jwtSecret) as any;
if (!decoded.pending_totp) {
const decoded = await authManager.verifyJWTToken(temp_token);
if (!decoded || !decoded.pendingTOTP) {
return res.status(401).json({ error: "Invalid temporary token" });
}
@@ -1267,17 +1480,42 @@ router.post("/totp/verify-login", async (req, res) => {
return res.status(400).json({ error: "TOTP not enabled for this user" });
}
const userDataKey = authManager.getUserDataKey(userRecord.id);
if (!userDataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
userRecord.totp_secret,
userDataKey,
userRecord.id,
"totp_secret",
);
const verified = speakeasy.totp.verify({
secret: userRecord.totp_secret,
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
const backupCodes = userRecord.totp_backup_codes
? JSON.parse(userRecord.totp_backup_codes)
: [];
let backupCodes = [];
try {
backupCodes = userRecord.totp_backup_codes
? JSON.parse(userRecord.totp_backup_codes)
: [];
} catch (parseError) {
backupCodes = [];
}
if (!Array.isArray(backupCodes)) {
backupCodes = [];
}
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
@@ -1291,15 +1529,44 @@ router.post("/totp/verify-login", async (req, res) => {
.where(eq(users.id, userRecord.id));
}
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "50d",
});
return res.json({
token,
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
const isDataUnlocked = authManager.isUserUnlocked(userRecord.id);
if (!isDataUnlocked) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const response: any = {
success: true,
is_admin: !!userRecord.is_admin,
username: userRecord.username,
});
userId: userRecord.id,
is_oidc: !!userRecord.is_oidc,
totp_enabled: !!userRecord.totp_enabled,
data_unlocked: isDataUnlocked,
};
if (isElectron) {
response.token = token;
}
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
)
.json(response);
} catch (err) {
authLogger.error("TOTP verification failed", err);
return res.status(500).json({ error: "TOTP verification failed" });
@@ -1606,4 +1873,117 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
}
});
// Route: User data unlock - used when session expires
// POST /users/unlock-data
router.post("/unlock-data", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: "Password is required" });
}
try {
const unlocked = await authManager.authenticateUser(userId, password);
if (unlocked) {
res.json({
success: true,
message: "Data unlocked successfully",
});
} else {
authLogger.warn("Failed to unlock user data - invalid password", {
operation: "user_data_unlock_failed",
userId,
});
res.status(401).json({ error: "Invalid password" });
}
} catch (err) {
authLogger.error("Data unlock failed", err, {
operation: "user_data_unlock_error",
userId,
});
res.status(500).json({ error: "Failed to unlock data" });
}
});
// Route: Check user data unlock status
// GET /users/data-status
router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const isUnlocked = authManager.isUserUnlocked(userId);
res.json({
unlocked: isUnlocked,
message: isUnlocked
? "Data is unlocked"
: "Data is locked - re-authenticate with password",
});
} catch (err) {
authLogger.error("Failed to check data status", err, {
operation: "data_status_check_failed",
userId,
});
res.status(500).json({ error: "Failed to check data status" });
}
});
// Route: Change user password (re-encrypt data keys)
// POST /users/change-password
router.post("/change-password", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
error: "Current password and new password are required",
});
}
if (newPassword.length < 8) {
return res.status(400).json({
error: "New password must be at least 8 characters long",
});
}
try {
const success = await authManager.changeUserPassword(
userId,
currentPassword,
newPassword,
);
if (success) {
const saltRounds = parseInt(process.env.SALT || "10", 10);
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
await db
.update(users)
.set({ password_hash: newPasswordHash })
.where(eq(users.id, userId));
authLogger.success("User password changed successfully", {
operation: "password_change_success",
userId,
});
res.json({
success: true,
message: "Password changed successfully",
});
} else {
authLogger.warn("Password change failed - invalid current password", {
operation: "password_change_failed",
userId,
});
res.status(401).json({ error: "Current password is incorrect" });
}
} catch (err) {
authLogger.error("Password change failed", err, {
operation: "password_change_error",
userId,
});
res.status(500).json({ error: "Failed to change password" });
}
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
import express from "express";
import net from "net";
import cors from "cors";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { db } from "../database/db/index.js";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
interface PooledConnection {
client: Client;
@@ -227,6 +230,7 @@ class MetricsCache {
const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache();
const authManager = AuthManager.getInstance();
type HostStatus = "online" | "offline";
@@ -275,7 +279,37 @@ function validateHostId(
const app = express();
app.use(
cors({
origin: "*",
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
];
// Allow any HTTPS origin (production deployments)
if (origin.startsWith("https://")) {
return callback(null, true);
}
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) {
return callback(null, true);
}
// Check against allowed development origins
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Reject other origins
callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
@@ -285,33 +319,28 @@ app.use(
],
}),
);
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, User-Agent, X-Electron-App",
);
res.header(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
);
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
// Add authentication middleware - Linus principle: eliminate special cases
app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
async function fetchAllHosts(
userId: string,
): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await db.select().from(sshData);
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
"ssh_data",
userId,
);
const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const host of hosts) {
try {
const hostWithCreds = await resolveHostCredentials(host);
const hostWithCreds = await resolveHostCredentials(host, userId);
if (hostWithCreds) {
hostsWithCredentials.push(hostWithCreds);
}
@@ -331,16 +360,34 @@ async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
async function fetchHostById(
id: number,
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
// Check if user data is unlocked before attempting to fetch
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked",
userId,
hostId: id,
});
return undefined;
}
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return undefined;
}
const host = hosts[0];
return await resolveHostCredentials(host);
return await resolveHostCredentials(host, userId);
} catch (err) {
statsLogger.error(`Failed to fetch host ${id}`, err);
return undefined;
@@ -349,6 +396,7 @@ async function fetchHostById(
async function resolveHostCredentials(
host: any,
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
const baseHost: any = {
@@ -380,15 +428,19 @@ async function resolveHostCredentials(
if (host.credentialId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId),
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, userId),
),
),
);
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
@@ -409,9 +461,6 @@ async function resolveHostCredentials(
baseHost.keyType = credential.keyType;
}
} else {
statsLogger.warn(
`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`,
);
addLegacyCredentials(baseHost, host);
}
} catch (error) {
@@ -446,7 +495,38 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
port: host.port || 22,
username: host.username || "root",
readyTimeout: 10_000,
algorithms: {},
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
} as ConnectConfig;
if (host.authType === "password") {
@@ -761,11 +841,19 @@ function tcpPing(
});
}
async function pollStatusesOnce(): Promise<void> {
const hosts = await fetchAllHosts();
async function pollStatusesOnce(userId?: string): Promise<void> {
if (!userId) {
statsLogger.warn("Skipping status poll - no authenticated user", {
operation: "status_poll",
});
return;
}
const hosts = await fetchAllHosts(userId);
if (hosts.length === 0) {
statsLogger.warn("No hosts retrieved for status polling", {
operation: "status_poll",
userId,
});
return;
}
@@ -797,8 +885,18 @@ async function pollStatusesOnce(): Promise<void> {
}
app.get("/status", async (req, res) => {
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (hostStatuses.size === 0) {
await pollStatusesOnce();
await pollStatusesOnce(userId);
}
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) {
@@ -809,9 +907,18 @@ app.get("/status", async (req, res) => {
app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const host = await fetchHostById(id);
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
@@ -832,15 +939,34 @@ app.get("/status/:id", validateHostId, async (req, res) => {
});
app.post("/refresh", async (req, res) => {
await pollStatusesOnce();
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
await pollStatusesOnce(userId);
res.json({ message: "Refreshed" });
});
app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const host = await fetchHostById(id);
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
@@ -882,28 +1008,22 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
});
process.on("SIGINT", () => {
statsLogger.info("Received SIGINT, shutting down gracefully");
connectionPool.destroy();
process.exit(0);
});
process.on("SIGTERM", () => {
statsLogger.info("Received SIGTERM, shutting down gracefully");
connectionPool.destroy();
process.exit(0);
});
const PORT = 8085;
const PORT = 30005;
app.listen(PORT, async () => {
statsLogger.success("Server Stats API server started", {
operation: "server_start",
port: PORT,
});
try {
await pollStatusesOnce();
await authManager.initialize();
} catch (err) {
statsLogger.error("Initial poll failed", err, {
operation: "initial_poll",
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
});

View File

@@ -1,33 +1,198 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { db } from "../database/db/index.js";
import { parse as parseUrl } from "url";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
const wss = new WebSocketServer({ port: 8082 });
const authManager = AuthManager.getInstance();
const userCrypto = UserCrypto.getInstance();
sshLogger.success("SSH Terminal WebSocket server started", {
operation: "server_start",
port: 8082,
const userConnections = new Map<string, Set<WebSocket>>();
const wss = new WebSocketServer({
port: 30002,
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url!, true);
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;
}
return true;
} catch (error) {
sshLogger.error("WebSocket authentication error", error, {
operation: "websocket_auth_error",
ip: info.req.socket.remoteAddress,
});
return false;
}
},
});
wss.on("connection", (ws: WebSocket) => {
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",
error,
{
operation: "websocket_connection_auth_error",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
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",
message: "Data locked - re-authenticate with password",
code: "DATA_LOCKED",
}),
);
ws.close(1008, "Data access required");
return;
}
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
const userWs = userConnections.get(userId)!;
userWs.add(ws);
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on("close", () => {
const userWs = userConnections.get(userId);
if (userWs) {
userWs.delete(ws);
if (userWs.size === 0) {
userConnections.delete(userId);
}
}
cleanupSSH();
});
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",
message: "Data access expired - please re-authenticate",
code: "DATA_EXPIRED",
}),
);
ws.close(1008, "Data access expired");
return;
}
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
sshLogger.error("Invalid JSON received", e, {
operation: "websocket_message",
operation: "websocket_message_invalid_json",
userId,
messageLength: msg.toString().length,
});
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
@@ -38,9 +203,13 @@ wss.on("connection", (ws: WebSocket) => {
switch (type) {
case "connectToHost":
if (data.hostConfig) {
data.hostConfig.userId = userId;
}
handleConnectToHost(data).catch((error) => {
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
userId,
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip,
});
@@ -81,7 +250,8 @@ wss.on("connection", (ws: WebSocket) => {
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message",
operation: "websocket_message_unknown_type",
userId,
messageType: type,
});
}
@@ -103,8 +273,10 @@ wss.on("connection", (ws: WebSocket) => {
credentialId?: number;
userId?: string;
};
initialPath?: string;
executeCommand?: string;
}) {
const { cols, rows, hostConfig } = data;
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
const {
id,
ip,
@@ -177,21 +349,25 @@ wss.on("connection", (ws: WebSocket) => {
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
),
);
"ssh_credentials",
hostConfig.userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.key,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,
@@ -281,6 +457,18 @@ wss.on("connection", (ws: WebSocket) => {
setupPingInterval();
if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
}
if (executeCommand && executeCommand.trim() !== "") {
setTimeout(() => {
const command = `${executeCommand}\n`;
stream.write(command);
}, 500);
}
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
@@ -389,11 +577,26 @@ wss.on("connection", (ws: WebSocket) => {
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
connectConfig.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
) {
try {
if (
!resolvedCredentials.key.includes("-----BEGIN") ||
@@ -439,7 +642,14 @@ wss.on("connection", (ws: WebSocket) => {
);
return;
} else {
connectConfig.password = resolvedCredentials.password;
sshLogger.error("No valid authentication method provided");
ws.send(
JSON.stringify({
type: "error",
message: "No valid authentication method provided",
}),
);
return;
}
sshConn.connect(connectConfig);

View File

@@ -1,9 +1,10 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { Client } from "ssh2";
import { ChildProcess } from "child_process";
import axios from "axios";
import { db } from "../database/db/index.js";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import type {
@@ -15,11 +16,38 @@ import type {
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { DataCrypto } from "../utils/data-crypto.js";
const app = express();
app.use(
cors({
origin: "*",
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 (origin.startsWith("https://")) {
return callback(null, true);
}
if (origin.startsWith("http://")) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [
"Origin",
@@ -32,6 +60,7 @@ app.use(
],
}),
);
app.use(cookieParser());
app.use(express.json());
const activeTunnels = new Map<string, Client>();
@@ -43,6 +72,8 @@ const verificationTimers = new Map<string, NodeJS.Timeout>();
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
const countdownIntervals = new Map<string, NodeJS.Timeout>();
const retryExhaustedTunnels = new Set<string>();
const cleanupInProgress = new Set<string>();
const tunnelConnecting = new Set<string>();
const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>();
@@ -123,16 +154,32 @@ function getTunnelMarker(tunnelName: string) {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
}
function cleanupTunnelResources(tunnelName: string): void {
function cleanupTunnelResources(
tunnelName: string,
forceCleanup = false,
): void {
if (cleanupInProgress.has(tunnelName)) {
return;
}
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
return;
}
cleanupInProgress.add(tunnelName);
const tunnelConfig = tunnelConfigs.get(tunnelName);
if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
cleanupInProgress.delete(tunnelName);
if (err) {
tunnelLogger.error(
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
);
}
});
} else {
cleanupInProgress.delete(tunnelName);
}
if (activeTunnelProcesses.has(tunnelName)) {
@@ -203,6 +250,8 @@ function cleanupTunnelResources(tunnelName: string): void {
function resetRetryState(tunnelName: string): void {
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
cleanupInProgress.delete(tunnelName);
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
@@ -394,7 +443,9 @@ async function connectSSHTunnel(
return;
}
cleanupTunnelResources(tunnelName);
tunnelConnecting.add(tunnelName);
cleanupTunnelResources(tunnelName, true);
if (retryAttempt === 0) {
retryExhaustedTunnels.delete(tunnelName);
@@ -441,31 +492,34 @@ async function connectSSHTunnel(
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId);
if (userDataKey) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
),
"ssh_credentials",
tunnelConfig.sourceUserId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
} else {
}
} else {
tunnelLogger.warn("No source credentials found in database", {
operation: "tunnel_connect",
tunnelName,
credentialId: tunnelConfig.sourceCredentialId,
});
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source credentials from database", {
@@ -485,33 +539,71 @@ async function connectSSHTunnel(
authMethod: tunnelConfig.endpointAuthMethod,
};
if (
resolvedEndpointCredentials.authMethod === "password" &&
!resolvedEndpointCredentials.password
) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
return;
}
if (
resolvedEndpointCredentials.authMethod === "key" &&
!resolvedEndpointCredentials.sshKey
) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
return;
}
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
eq(sshCredentials.userId, tunnelConfig.endpointUserId),
),
const userDataKey = DataCrypto.getUserDataKey(
tunnelConfig.endpointUserId,
);
if (userDataKey) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
eq(sshCredentials.userId, tunnelConfig.endpointUserId),
),
),
"ssh_credentials",
tunnelConfig.endpointUserId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
if (credentials.length > 0) {
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
operation: "tunnel_connect",
tunnelName,
credentialId: tunnelConfig.endpointCredentialId,
});
}
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
operation: "tunnel_connect",
tunnelName,
credentialId: tunnelConfig.endpointCredentialId,
});
}
} catch (error) {
tunnelLogger.warn(
@@ -555,6 +647,8 @@ async function connectSSHTunnel(
clearTimeout(connectionTimeout);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
return;
}
@@ -583,6 +677,8 @@ async function connectSSHTunnel(
conn.on("close", () => {
clearTimeout(connectionTimeout);
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
return;
}
@@ -620,9 +716,9 @@ async function connectSSHTunnel(
resolvedEndpointCredentials.sshKey
) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
} else {
tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
}
conn.exec(tunnelCmd, (err, stream) => {
@@ -651,6 +747,8 @@ async function connectSSHTunnel(
!manualDisconnects.has(tunnelName) &&
activeTunnels.has(tunnelName)
) {
tunnelConnecting.delete(tunnelName);
broadcastTunnelStatus(tunnelName, {
connected: true,
status: CONNECTION_STATES.CONNECTED,
@@ -722,12 +820,68 @@ async function connectSSHTunnel(
}
});
stream.stdout?.on("data", (data: Buffer) => {});
stream.stdout?.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
}
});
stream.on("error", (err: Error) => {});
stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
if (errorMsg) {
const isDebugMessage =
errorMsg.startsWith("debug1:") ||
errorMsg.startsWith("debug2:") ||
errorMsg.startsWith("debug3:") ||
errorMsg.includes("Reading configuration data") ||
errorMsg.includes("include /etc/ssh/ssh_config.d") ||
errorMsg.includes("matched no files") ||
errorMsg.includes("Applying options for");
if (!isDebugMessage) {
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
}
if (
errorMsg.includes("sshpass: command not found") ||
errorMsg.includes("sshpass not found")
) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason:
"sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
});
}
if (
errorMsg.includes("remote port forwarding failed") ||
errorMsg.includes("Error: remote port forwarding failed")
) {
const portMatch = errorMsg.match(/listen port (\d+)/);
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
tunnelLogger.error(
`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`,
);
if (activeTunnels.has(tunnelName)) {
const conn = activeTunnels.get(tunnelName);
if (conn) {
conn.end();
}
activeTunnels.delete(tunnelName);
}
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: `Remote port forwarding failed for port ${port}. Port may be in use, requires root privileges, or SSH server doesn't allow port forwarding. Try a different port.`,
});
}
}
});
});
});
@@ -763,7 +917,14 @@ async function connectSSHTunnel(
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
@@ -827,12 +988,60 @@ async function connectSSHTunnel(
conn.connect(connOptions);
}
function killRemoteTunnelByMarker(
async function killRemoteTunnelByMarker(
tunnelConfig: TunnelConfig,
tunnelName: string,
callback: (err?: Error) => void,
) {
const tunnelMarker = getTunnelMarker(tunnelName);
let resolvedSourceCredentials = {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
};
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
try {
const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId);
if (userDataKey) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
),
"ssh_credentials",
tunnelConfig.sourceUserId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
}
} else {
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
tunnelName,
credentialId: tunnelConfig.sourceCredentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
const conn = new Client();
const connOptions: any = {
host: tunnelConfig.sourceIP,
@@ -865,52 +1074,149 @@ function killRemoteTunnelByMarker(
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
if (
!tunnelConfig.sourceSSHKey.includes("-----BEGIN") ||
!tunnelConfig.sourceSSHKey.includes("-----END")
!resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
!resolvedSourceCredentials.sshKey.includes("-----END")
) {
callback(new Error("Invalid SSH key format"));
return;
}
const cleanKey = tunnelConfig.sourceSSHKey
const cleanKey = resolvedSourceCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== "auto") {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
if (
resolvedSourceCredentials.keyType &&
resolvedSourceCredentials.keyType !== "auto"
) {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
}
} else {
connOptions.password = tunnelConfig.sourcePassword;
connOptions.password = resolvedSourceCredentials.password;
}
conn.on("ready", () => {
const killCmd = `pkill -f '${tunnelMarker}'`;
conn.exec(killCmd, (err, stream) => {
if (err) {
conn.end();
callback(err);
return;
}
stream.on("close", () => {
conn.end();
callback();
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) => {
let foundProcesses = false;
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
foundProcesses = true;
}
});
stream.on("close", () => {
if (!foundProcesses) {
conn.end();
callback();
return;
}
const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
];
let commandIndex = 0;
function executeNextKillCommand() {
if (commandIndex >= killCmds.length) {
conn.exec(checkCmd, (err, verifyStream) => {
let stillRunning = false;
verifyStream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
stillRunning = true;
tunnelLogger.warn(
`Processes still running after cleanup for '${tunnelName}': ${output}`,
);
}
});
verifyStream.on("close", () => {
if (stillRunning) {
tunnelLogger.warn(
`Some tunnel processes may still be running for '${tunnelName}'`,
);
}
conn.end();
callback();
});
});
return;
}
const killCmd = killCmds[commandIndex];
conn.exec(killCmd, (err, stream) => {
if (err) {
tunnelLogger.warn(
`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
);
} else {
}
stream.on("close", (code) => {
commandIndex++;
executeNextKillCommand();
});
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
}
});
stream.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output && !output.includes("debug1")) {
tunnelLogger.warn(
`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`,
);
}
});
});
}
executeNextKillCommand();
});
stream.on("data", () => {});
stream.stderr.on("data", () => {});
});
});
conn.on("error", (err) => {
tunnelLogger.error(
`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`,
);
callback(err);
});
conn.connect(connOptions);
}
@@ -938,6 +1244,8 @@ app.post("/ssh/tunnel/connect", (req, res) => {
const tunnelName = tunnelConfig.name;
cleanupTunnelResources(tunnelName);
manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
@@ -969,6 +1277,8 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
activeRetryTimers.delete(tunnelName);
}
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
@@ -1005,6 +1315,8 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
countdownIntervals.delete(tunnelName);
}
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
@@ -1023,24 +1335,42 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
async function initializeAutoStartTunnels(): Promise<void> {
try {
const response = await axios.get(
"http://localhost:8081/ssh/db/host/internal",
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const autostartResponse = await axios.get(
"http://localhost:30001/ssh/db/host/internal",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "1",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);
const hosts: SSHHost[] = response.data || [];
const allHostsResponse = await axios.get(
"http://localhost:30001/ssh/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);
const autostartHosts: SSHHost[] = autostartResponse.data || [];
const allHosts: SSHHost[] = allHostsResponse.data || [];
const autoStartTunnels: TunnelConfig[] = [];
for (const host of hosts) {
tunnelLogger.info(
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
);
for (const host of autostartHosts) {
if (host.enableTunnel && host.tunnelConnections) {
for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) {
const endpointHost = hosts.find(
const endpointHost = allHosts.find(
(h) =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost,
@@ -1053,19 +1383,35 @@ async function initializeAutoStartTunnels(): Promise<void> {
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourcePassword: host.autostartPassword || host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceSSHKey: host.autostartKey || host.key,
sourceKeyPassword:
host.autostartKeyPassword || host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.password,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.key,
endpointKeyPassword: endpointHost.keyPassword,
endpointKeyType: endpointHost.keyType,
endpointPassword:
tunnelConnection.endpointPassword ||
endpointHost.autostartPassword ||
endpointHost.password,
endpointAuthMethod:
tunnelConnection.endpointAuthType || endpointHost.authType,
endpointSSHKey:
tunnelConnection.endpointKey ||
endpointHost.autostartKey ||
endpointHost.key,
endpointKeyPassword:
tunnelConnection.endpointKeyPassword ||
endpointHost.autostartKeyPassword ||
endpointHost.keyPassword,
endpointKeyType:
tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
@@ -1074,15 +1420,25 @@ 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(
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map((h) => h.name || `${h.username}@${h.ip}`).join(", ")}`,
);
}
}
}
}
}
tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
for (const tunnelConfig of autoStartTunnels) {
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
@@ -1102,12 +1458,8 @@ async function initializeAutoStartTunnels(): Promise<void> {
}
}
const PORT = 8083;
const PORT = 30003;
app.listen(PORT, () => {
tunnelLogger.success("SSH Tunnel API server started", {
operation: "server_start",
port: PORT,
});
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);

View File

@@ -1,31 +1,107 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import "./database/database.js";
import "./ssh/terminal.js";
import "./ssh/tunnel.js";
import "./ssh/file-manager.js";
import "./ssh/server-stats.js";
import dotenv from "dotenv";
import { promises as fs } from "fs";
import { readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
import { SystemCrypto } from "./utils/system-crypto.js";
import { systemLogger, versionLogger } from "./utils/logger.js";
import "dotenv/config";
(async () => {
try {
const version = process.env.VERSION || "unknown";
dotenv.config({ quiet: true });
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
await fs.access(envPath);
const persistentConfig = dotenv.config({ path: envPath, quiet: true });
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
} catch {}
let version = "unknown";
const versionSources = [
() => process.env.VERSION,
() => {
try {
const packageJsonPath = path.join(process.cwd(), "package.json");
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const __filename = fileURLToPath(import.meta.url);
const packageJsonPath = path.join(
path.dirname(__filename),
"../../../package.json",
);
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packageJsonPath = path.join("/app", "package.json");
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
return packageJson.version;
} catch {
return null;
}
},
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
version = foundVersion;
break;
}
} catch (error) {
continue;
}
}
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: "startup",
version: version,
});
systemLogger.info("Initializing backend services...", {
operation: "startup",
});
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeInternalAuthToken();
systemLogger.success("All backend services initialized successfully", {
operation: "startup_complete",
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
version: version,
});
await AutoSSLSetup.initialize();
const dbModule = await import("./database/db/index.js");
await dbModule.initializeDatabase();
const authManager = AuthManager.getInstance();
await authManager.initialize();
DataCrypto.initialize();
await import("./database/database.js");
await import("./ssh/terminal.js");
await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js");
await import("./ssh/server-stats.js");
process.on("SIGINT", () => {
systemLogger.info(

View File

@@ -0,0 +1,300 @@
import jwt from "jsonwebtoken";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
interface AuthenticationResult {
success: boolean;
token?: string;
userId?: string;
isAdmin?: boolean;
username?: string;
requiresTOTP?: boolean;
tempToken?: string;
error?: string;
}
interface JWTPayload {
userId: string;
pendingTOTP?: boolean;
iat?: number;
exp?: number;
}
class AuthManager {
private static instance: AuthManager;
private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = new Set();
private constructor() {
this.systemCrypto = SystemCrypto.getInstance();
this.userCrypto = UserCrypto.getInstance();
this.userCrypto.setSessionExpiredCallback((userId: string) => {
this.invalidateUserTokens(userId);
});
}
static getInstance(): AuthManager {
if (!this.instance) {
this.instance = new AuthManager();
}
return this.instance;
}
async initialize(): Promise<void> {
await this.systemCrypto.initializeJWTSecret();
}
async registerUser(userId: string, password: string): Promise<void> {
await this.userCrypto.setupUserEncryption(userId, password);
}
async registerOIDCUser(userId: string): Promise<void> {
await this.userCrypto.setupOIDCUserEncryption(userId);
}
async authenticateOIDCUser(userId: string): Promise<boolean> {
const authenticated = await this.userCrypto.authenticateOIDCUser(userId);
if (authenticated) {
await this.performLazyEncryptionMigration(userId);
}
return authenticated;
}
async authenticateUser(userId: string, password: string): Promise<boolean> {
const authenticated = await this.userCrypto.authenticateUser(
userId,
password,
);
if (authenticated) {
await this.performLazyEncryptionMigration(userId);
}
return authenticated;
}
private async performLazyEncryptionMigration(userId: string): Promise<void> {
try {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.warn(
"Cannot perform lazy encryption migration - user data key not available",
{
operation: "lazy_encryption_migration_no_key",
userId,
},
);
return;
}
const { getSqlite, saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
const sqlite = getSqlite();
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
userId,
userDataKey,
sqlite,
);
if (migrationResult.migrated) {
await saveMemoryDatabaseToFile();
} else {
}
} catch (error) {
databaseLogger.error("Lazy encryption migration failed", error, {
operation: "lazy_encryption_migration_error",
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
async generateJWTToken(
userId: string,
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
payload.pendingTOTP = true;
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "24h",
} as jwt.SignOptions);
}
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;
return payload;
} catch (error) {
databaseLogger.warn("JWT verification failed", {
operation: "jwt_verify_failed",
error: error instanceof Error ? error.message : "Unknown error",
});
return null;
}
}
invalidateJWTToken(token: string): void {
this.invalidatedTokens.add(token);
}
invalidateUserTokens(userId: string): void {
databaseLogger.info("User tokens invalidated due to data lock", {
operation: "user_tokens_invalidate",
userId,
});
}
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
return {
httpOnly: false,
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const,
maxAge: maxAge,
path: "/",
};
}
createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
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 payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
}
(req as any).userId = payload.userId;
(req as any).pendingTOTP = payload.pendingTOTP;
next();
};
}
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = (req as any).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;
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" });
}
const token = authHeader.split(" ")[1];
const payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
}
try {
const { db } = await import("../database/db/index.js");
const { users } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId));
if (!user || user.length === 0 || !user[0].is_admin) {
databaseLogger.warn(
"Non-admin user attempted to access admin endpoint",
{
operation: "admin_access_denied",
userId: payload.userId,
endpoint: req.path,
},
);
return res.status(403).json({ error: "Admin access required" });
}
(req as any).userId = payload.userId;
(req as any).pendingTOTP = payload.pendingTOTP;
next();
} catch (error) {
databaseLogger.error("Failed to verify admin privileges", error, {
operation: "admin_check_failed",
userId: payload.userId,
});
return res
.status(500)
.json({ error: "Failed to verify admin privileges" });
}
};
}
logoutUser(userId: string): void {
this.userCrypto.logoutUser(userId);
}
getUserDataKey(userId: string): Buffer | null {
return this.userCrypto.getUserDataKey(userId);
}
isUserUnlocked(userId: string): boolean {
return this.userCrypto.isUserUnlocked(userId);
}
async changeUserPassword(
userId: string,
oldPassword: string,
newPassword: string,
): Promise<boolean> {
return await this.userCrypto.changeUserPassword(
userId,
oldPassword,
newPassword,
);
}
}
export { AuthManager, type AuthenticationResult, type JWTPayload };

View File

@@ -0,0 +1,280 @@
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 {
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
private static readonly CERT_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.crt",
);
private static readonly KEY_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.key",
);
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
static async initialize(): Promise<void> {
if (process.env.ENABLE_SSL !== "true") {
systemLogger.info("SSL not enabled - skipping certificate generation", {
operation: "ssl_disabled_default",
enable_ssl: process.env.ENABLE_SSL || "undefined",
note: "Set ENABLE_SSL=true to enable SSL certificate generation",
});
return;
}
try {
if (await this.isSSLConfigured()) {
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
}
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
systemLogger.info("SSL certificates found from entrypoint script", {
operation: "ssl_cert_found_entrypoint",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
});
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
} catch {
await this.generateSSLCertificates();
await this.setupEnvironmentVariables();
}
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed",
});
systemLogger.warn("Falling back to HTTP-only mode", {
operation: "ssl_fallback_http",
});
}
}
private static async isSSLConfigured(): Promise<boolean> {
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
execSync(
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
{
stdio: "pipe",
},
);
return true;
} catch (error) {
if (error instanceof Error && error.message.includes("checkend")) {
systemLogger.warn(
"SSL certificate is expired or expiring soon, will regenerate",
{
operation: "ssl_cert_expired",
cert_path: this.CERT_FILE,
error: error.message,
},
);
} else {
systemLogger.info(
"SSL certificate not found or invalid, will generate new one",
{
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
},
);
}
return false;
}
}
private static async generateSSLCertificates(): Promise<void> {
try {
try {
execSync("openssl version", { stdio: "pipe" });
} catch (error) {
throw new Error(
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
);
}
await fs.mkdir(this.SSL_DIR, { recursive: true });
const configFile = path.join(this.SSL_DIR, "openssl.conf");
const opensslConfig = `
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=localhost
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
DNS.3 = *.localhost
DNS.4 = termix.local
DNS.5 = *.termix.local
IP.1 = 127.0.0.1
IP.2 = ::1
IP.3 = 0.0.0.0
`.trim();
await fs.writeFile(configFile, opensslConfig);
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
stdio: "pipe",
});
execSync(
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
{
stdio: "pipe",
},
);
await fs.chmod(this.KEY_FILE, 0o600);
await fs.chmod(this.CERT_FILE, 0o644);
await fs.unlink(configFile);
systemLogger.success("SSL certificates generated successfully", {
operation: "ssl_cert_generated",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
valid_days: 365,
});
await this.logCertificateInfo();
} catch (error) {
throw new Error(
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
private static async logCertificateInfo(): Promise<void> {
try {
const subject = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
{ stdio: "pipe" },
)
.toString()
.trim();
const issuer = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notAfter = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notBefore = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
{ stdio: "pipe" },
)
.toString()
.trim();
systemLogger.info("SSL Certificate Information:", {
operation: "ssl_cert_info",
subject: subject.replace("subject=", ""),
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
note: "Certificate will auto-renew 30 days before expiration",
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
operation: "ssl_cert_info_error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
private static async setupEnvironmentVariables(): Promise<void> {
const certPath = this.CERT_FILE;
const keyPath = this.KEY_FILE;
const sslEnvVars = {
ENABLE_SSL: "false",
SSL_PORT: process.env.SSL_PORT || "8443",
SSL_CERT_PATH: certPath,
SSL_KEY_PATH: keyPath,
SSL_DOMAIN: "localhost",
};
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch {}
let updatedContent = envContent;
let hasChanges = false;
for (const [key, value] of Object.entries(sslEnvVars)) {
const regex = new RegExp(`^${key}=.*$`, "m");
if (regex.test(updatedContent)) {
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
} else {
if (!updatedContent.includes(`# SSL Configuration`)) {
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
}
updatedContent += `${key}=${value}\n`;
hasChanges = true;
}
}
if (hasChanges || !envContent) {
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
systemLogger.info("SSL environment variables configured", {
operation: "ssl_env_configured",
file: this.ENV_FILE,
variables: Object.keys(sslEnvVars),
});
}
for (const [key, value] of Object.entries(sslEnvVars)) {
process.env[key] = value;
}
}
static getSSLConfig() {
return {
enabled: process.env.ENABLE_SSL === "true",
port: parseInt(process.env.SSL_PORT || "8443"),
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
domain: process.env.SSL_DOMAIN || "localhost",
};
}
}

View File

@@ -0,0 +1,284 @@
import { FieldCrypto } from "./field-crypto.js";
import { LazyFieldEncryption } from "./lazy-field-encryption.js";
import { UserCrypto } from "./user-crypto.js";
import { databaseLogger } from "./logger.js";
class DataCrypto {
private static userCrypto: UserCrypto;
static initialize() {
this.userCrypto = UserCrypto.getInstance();
}
static encryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
const encryptedRecord = { ...record };
const recordId = record.id || "temp-" + Date.now();
for (const [fieldName, value] of Object.entries(record)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
encryptedRecord[fieldName] = FieldCrypto.encryptField(
value as string,
userDataKey,
recordId,
fieldName,
);
}
}
return encryptedRecord;
}
static decryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
if (!record) return record;
const decryptedRecord = { ...record };
const recordId = record.id;
for (const [fieldName, value] of Object.entries(record)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
value as string,
userDataKey,
recordId,
fieldName,
);
}
}
return decryptedRecord;
}
static decryptRecords(
tableName: string,
records: any[],
userId: string,
userDataKey: Buffer,
): any[] {
if (!Array.isArray(records)) return records;
return records.map((record) =>
this.decryptRecord(tableName, record, userId, userDataKey),
);
}
static async migrateUserSensitiveFields(
userId: string,
userDataKey: Buffer,
db: any,
): Promise<{
migrated: boolean;
migratedTables: string[];
migratedFieldsCount: number;
}> {
let migrated = false;
const migratedTables: string[] = [];
let migratedFieldsCount = 0;
try {
const { needsMigration, plaintextFields } =
await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
db,
);
if (!needsMigration) {
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
const sshDataRecords = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
for (const record of sshDataRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString(),
);
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
record.id,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("ssh_data")) {
migratedTables.push("ssh_data");
}
migrated = true;
}
}
const sshCredentialsRecords = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
for (const record of sshCredentialsRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString(),
);
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.private_key || null,
record.id,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("ssh_credentials")) {
migratedTables.push("ssh_credentials");
}
migrated = true;
}
}
const userRecord = db
.prepare("SELECT * FROM users WHERE id = ?")
.get(userId);
if (userRecord) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("users");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
userRecord,
sensitiveFields,
userDataKey,
userId,
);
if (needsUpdate) {
const updateQuery = `
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.totp_secret || null,
updatedRecord.totp_backup_codes || null,
userId,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("users")) {
migratedTables.push("users");
}
migrated = true;
}
}
return { migrated, migratedTables, migratedFieldsCount };
} catch (error) {
databaseLogger.error("User sensitive fields migration failed", error, {
operation: "user_sensitive_migration_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
}
static getUserDataKey(userId: string): Buffer | null {
return this.userCrypto.getUserDataKey(userId);
}
static validateUserAccess(userId: string): Buffer {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) {
throw new Error(`User ${userId} data not unlocked`);
}
return userDataKey;
}
static encryptRecordForUser(
tableName: string,
record: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.encryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordForUser(
tableName: string,
record: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordsForUser(
tableName: string,
records: any[],
userId: string,
): any[] {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecords(tableName, records, userId, userDataKey);
}
static canUserAccessData(userId: string): boolean {
return this.userCrypto.isUserUnlocked(userId);
}
static testUserEncryption(userId: string): boolean {
try {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) return false;
const testData = "test-" + Date.now();
const encrypted = FieldCrypto.encryptField(
testData,
userDataKey,
"test-record",
"test-field",
);
const decrypted = FieldCrypto.decryptField(
encrypted,
userDataKey,
"test-record",
"test-field",
);
return decrypted === testData;
} catch (error) {
return false;
}
}
}
export { DataCrypto };

View File

@@ -0,0 +1,400 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
import { SystemCrypto } from "./system-crypto.js";
interface EncryptedFileMetadata {
iv: string;
tag: string;
version: string;
fingerprint: string;
algorithm: string;
keySource?: string;
salt?: string;
}
class DatabaseFileEncryption {
private static readonly VERSION = "v2";
private static readonly ALGORITHM = "aes-256-gcm";
private static readonly ENCRYPTED_FILE_SUFFIX = ".encrypted";
private static readonly METADATA_FILE_SUFFIX = ".meta";
private static systemCrypto = SystemCrypto.getInstance();
static async encryptDatabaseFromBuffer(
buffer: Buffer,
targetPath: string,
): Promise<string> {
try {
const key = await this.systemCrypto.getDatabaseKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
const tag = cipher.getAuthTag();
const metadata: EncryptedFileMetadata = {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
version: this.VERSION,
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
};
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
fs.writeFileSync(targetPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
return targetPath;
} catch (error) {
databaseLogger.error("Failed to encrypt database buffer", error, {
operation: "database_buffer_encryption_failed",
targetPath,
});
throw new Error(
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static async encryptDatabaseFile(
sourcePath: string,
targetPath?: string,
): Promise<string> {
if (!fs.existsSync(sourcePath)) {
throw new Error(`Source database file does not exist: ${sourcePath}`);
}
const encryptedPath =
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
try {
const sourceData = fs.readFileSync(sourcePath);
const key = await this.systemCrypto.getDatabaseKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([
cipher.update(sourceData),
cipher.final(),
]);
const tag = cipher.getAuthTag();
const metadata: EncryptedFileMetadata = {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
version: this.VERSION,
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
};
fs.writeFileSync(encryptedPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
databaseLogger.info("Database file encrypted successfully", {
operation: "database_file_encryption",
sourcePath,
encryptedPath,
fileSize: sourceData.length,
encryptedSize: encrypted.length,
fingerprintPrefix: metadata.fingerprint,
});
return encryptedPath;
} catch (error) {
databaseLogger.error("Failed to encrypt database file", error, {
operation: "database_file_encryption_failed",
sourcePath,
targetPath: encryptedPath,
});
throw new Error(
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
if (!fs.existsSync(encryptedPath)) {
throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
}
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(`Metadata file does not exist: ${metadataPath}`);
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const encryptedData = fs.readFileSync(encryptedPath);
let key: Buffer;
if (metadata.version === "v2") {
key = await this.systemCrypto.getDatabaseKey();
} else if (metadata.version === "v1") {
databaseLogger.warn(
"Decrypting legacy v1 encrypted database - consider upgrading",
{
operation: "decrypt_legacy_v1",
path: encryptedPath,
},
);
if (!metadata.salt) {
throw new Error("v1 encrypted file missing required salt field");
}
const salt = Buffer.from(metadata.salt, "hex");
const fixedSeed =
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
} else {
throw new Error(`Unsupported encryption version: ${metadata.version}`);
}
const decipher = crypto.createDecipheriv(
metadata.algorithm,
key,
Buffer.from(metadata.iv, "hex"),
) as any;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decryptedBuffer = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
return decryptedBuffer;
} catch (error) {
databaseLogger.error("Failed to decrypt database to buffer", error, {
operation: "database_buffer_decryption_failed",
encryptedPath,
});
throw new Error(
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static async decryptDatabaseFile(
encryptedPath: string,
targetPath?: string,
): Promise<string> {
if (!fs.existsSync(encryptedPath)) {
throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
}
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(`Metadata file does not exist: ${metadataPath}`);
}
const decryptedPath =
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const encryptedData = fs.readFileSync(encryptedPath);
let key: Buffer;
if (metadata.version === "v2") {
key = await this.systemCrypto.getDatabaseKey();
} else if (metadata.version === "v1") {
databaseLogger.warn(
"Decrypting legacy v1 encrypted database - consider upgrading",
{
operation: "decrypt_legacy_v1",
path: encryptedPath,
},
);
if (!metadata.salt) {
throw new Error("v1 encrypted file missing required salt field");
}
const salt = Buffer.from(metadata.salt, "hex");
const fixedSeed =
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
} else {
throw new Error(`Unsupported encryption version: ${metadata.version}`);
}
const decipher = crypto.createDecipheriv(
metadata.algorithm,
key,
Buffer.from(metadata.iv, "hex"),
) as any;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
fs.writeFileSync(decryptedPath, decrypted);
databaseLogger.info("Database file decrypted successfully", {
operation: "database_file_decryption",
encryptedPath,
decryptedPath,
encryptedSize: encryptedData.length,
decryptedSize: decrypted.length,
fingerprintPrefix: metadata.fingerprint,
});
return decryptedPath;
} catch (error) {
databaseLogger.error("Failed to decrypt database file", error, {
operation: "database_file_decryption_failed",
encryptedPath,
targetPath: decryptedPath,
});
throw new Error(
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static isEncryptedDatabaseFile(filePath: string): boolean {
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) {
return false;
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
return (
metadata.version === this.VERSION &&
metadata.algorithm === this.ALGORITHM
);
} catch {
return false;
}
}
static getEncryptedFileInfo(encryptedPath: string): {
version: string;
algorithm: string;
fingerprint: string;
isCurrentHardware: boolean;
fileSize: number;
} | null {
if (!this.isEncryptedDatabaseFile(encryptedPath)) {
return null;
}
try {
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileStats = fs.statSync(encryptedPath);
const currentFingerprint = "termix-v1-file";
return {
version: metadata.version,
algorithm: metadata.algorithm,
fingerprint: metadata.fingerprint,
isCurrentHardware: true,
fileSize: fileStats.size,
};
} catch {
return null;
}
}
static async createEncryptedBackup(
databasePath: string,
backupDir: string,
): Promise<string> {
if (!fs.existsSync(databasePath)) {
throw new Error(`Database file does not exist: ${databasePath}`);
}
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
const backupPath = path.join(backupDir, backupFileName);
try {
const encryptedPath = await this.encryptDatabaseFile(
databasePath,
backupPath,
);
return encryptedPath;
} catch (error) {
databaseLogger.error("Failed to create encrypted backup", error, {
operation: "database_backup_failed",
sourcePath: databasePath,
backupDir,
});
throw error;
}
}
static async restoreFromEncryptedBackup(
backupPath: string,
targetPath: string,
): Promise<string> {
if (!this.isEncryptedDatabaseFile(backupPath)) {
throw new Error("Invalid encrypted backup file");
}
try {
const restoredPath = await this.decryptDatabaseFile(
backupPath,
targetPath,
);
return restoredPath;
} catch (error) {
databaseLogger.error("Failed to restore from encrypted backup", error, {
operation: "database_restore_failed",
backupPath,
targetPath,
});
throw error;
}
}
static cleanupTempFiles(basePath: string): void {
try {
const tempFiles = [
`${basePath}.tmp`,
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}`,
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}`,
];
for (const tempFile of tempFiles) {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
} catch (error) {
databaseLogger.warn("Failed to clean up temporary files", {
operation: "temp_cleanup_failed",
basePath,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export { DatabaseFileEncryption };
export type { EncryptedFileMetadata };

View File

@@ -0,0 +1,404 @@
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
import { DatabaseFileEncryption } from "./database-file-encryption.js";
export interface MigrationResult {
success: boolean;
error?: string;
migratedTables: number;
migratedRows: number;
backupPath?: string;
duration: number;
}
export interface MigrationStatus {
needsMigration: boolean;
hasUnencryptedDb: boolean;
hasEncryptedDb: boolean;
unencryptedDbSize: number;
reason: string;
}
export class DatabaseMigration {
private dataDir: string;
private unencryptedDbPath: string;
private encryptedDbPath: string;
constructor(dataDir: string) {
this.dataDir = dataDir;
this.unencryptedDbPath = path.join(dataDir, "db.sqlite");
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
}
checkMigrationStatus(): MigrationStatus {
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(
this.encryptedDbPath,
);
let unencryptedDbSize = 0;
if (hasUnencryptedDb) {
try {
unencryptedDbSize = fs.statSync(this.unencryptedDbPath).size;
} catch (error) {
databaseLogger.warn("Could not get unencrypted database file size", {
operation: "migration_status_check",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
let needsMigration = false;
let reason = "";
if (hasEncryptedDb && hasUnencryptedDb) {
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
const encryptedSize = fs.statSync(this.encryptedDbPath).size;
if (unencryptedSize === 0) {
needsMigration = false;
reason =
"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",
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else {
needsMigration = false;
reason =
"Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
}
} else if (hasEncryptedDb && !hasUnencryptedDb) {
needsMigration = false;
reason = "Only encrypted database exists. No migration needed.";
} else if (!hasEncryptedDb && hasUnencryptedDb) {
needsMigration = true;
reason =
"Unencrypted database found. Migration to encrypted format required.";
} else {
needsMigration = false;
reason = "No existing database found. This is a fresh installation.";
}
return {
needsMigration,
hasUnencryptedDb,
hasEncryptedDb,
unencryptedDbSize,
reason,
};
}
private createBackup(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
try {
fs.copyFileSync(this.unencryptedDbPath, backupPath);
const originalSize = fs.statSync(this.unencryptedDbPath).size;
const backupSize = fs.statSync(backupPath).size;
if (originalSize !== backupSize) {
throw new Error(
`Backup size mismatch: original=${originalSize}, backup=${backupSize}`,
);
}
return backupPath;
} catch (error) {
databaseLogger.error("Failed to create migration backup", error, {
operation: "migration_backup_failed",
source: this.unencryptedDbPath,
backup: backupPath,
});
throw new Error(
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
private async verifyMigration(
originalDb: Database.Database,
memoryDb: Database.Database,
): Promise<boolean> {
try {
memoryDb.exec("PRAGMA foreign_keys = OFF");
const originalTables = originalDb
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as { name: string }[];
const memoryTables = memoryDb
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as { name: string }[];
if (originalTables.length !== memoryTables.length) {
databaseLogger.error(
"Table count mismatch during migration verification",
null,
{
operation: "migration_verify_failed",
originalCount: originalTables.length,
memoryCount: memoryTables.length,
},
);
return false;
}
let totalOriginalRows = 0;
let totalMemoryRows = 0;
for (const table of originalTables) {
const originalCount = originalDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
const memoryCount = memoryDb
.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",
null,
{
operation: "migration_verify_table_failed",
table: table.name,
originalRows: originalCount.count,
memoryRows: memoryCount.count,
},
);
return false;
}
}
memoryDb.exec("PRAGMA foreign_keys = ON");
return true;
} catch (error) {
databaseLogger.error("Migration verification failed", error, {
operation: "migration_verify_error",
});
return false;
}
}
async migrateDatabase(): Promise<MigrationResult> {
const startTime = Date.now();
let backupPath: string | undefined;
let migratedTables = 0;
let migratedRows = 0;
try {
backupPath = this.createBackup();
const originalDb = new Database(this.unencryptedDbPath, {
readonly: true,
});
const memoryDb = new Database(":memory:");
try {
const tables = originalDb
.prepare(
`
SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`,
)
.all() as { name: string; sql: string }[];
for (const table of tables) {
memoryDb.exec(table.sql);
migratedTables++;
}
memoryDb.exec("PRAGMA foreign_keys = OFF");
for (const table of tables) {
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const placeholders = columns.map(() => "?").join(", ");
const insertStmt = memoryDb.prepare(
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
);
const insertTransaction = memoryDb.transaction(
(dataRows: any[]) => {
for (const row of dataRows) {
const values = columns.map((col) => row[col]);
insertStmt.run(values);
}
},
);
insertTransaction(rows);
migratedRows += rows.length;
}
}
memoryDb.exec("PRAGMA foreign_keys = ON");
const fkCheckResult = memoryDb
.prepare("PRAGMA foreign_key_check")
.all();
if (fkCheckResult.length > 0) {
databaseLogger.error(
"Foreign key constraints violations detected after migration",
null,
{
operation: "migration_fk_check_failed",
violations: fkCheckResult,
},
);
throw new Error(
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
);
}
const verificationPassed = await this.verifyMigration(
originalDb,
memoryDb,
);
if (!verificationPassed) {
throw new Error("Migration integrity verification failed");
}
const buffer = memoryDb.serialize();
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
this.encryptedDbPath,
);
if (
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
) {
throw new Error("Encrypted database file verification failed");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
fs.renameSync(this.unencryptedDbPath, migratedPath);
databaseLogger.success("Database migration completed successfully", {
operation: "migration_complete",
migratedTables,
migratedRows,
duration: Date.now() - startTime,
backupPath,
migratedPath,
encryptedDbPath: this.encryptedDbPath,
});
return {
success: true,
migratedTables,
migratedRows,
backupPath,
duration: Date.now() - startTime,
};
} finally {
originalDb.close();
memoryDb.close();
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
databaseLogger.error("Database migration failed", error, {
operation: "migration_failed",
migratedTables,
migratedRows,
duration: Date.now() - startTime,
backupPath,
});
return {
success: false,
error: errorMessage,
migratedTables,
migratedRows,
backupPath,
duration: Date.now() - startTime,
};
}
}
cleanupOldBackups(): void {
try {
const backupPattern =
/\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const migratedPattern =
/\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const files = fs.readdirSync(this.dataDir);
const backupFiles = files
.filter((f) => backupPattern.test(f))
.map((f) => ({
name: f,
path: path.join(this.dataDir, f),
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
}))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
const migratedFiles = files
.filter((f) => migratedPattern.test(f))
.map((f) => ({
name: f,
path: path.join(this.dataDir, f),
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
}))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
const backupsToDelete = backupFiles.slice(3);
const migratedToDelete = migratedFiles.slice(3);
for (const file of [...backupsToDelete, ...migratedToDelete]) {
try {
fs.unlinkSync(file.path);
} catch (error) {
databaseLogger.warn("Failed to cleanup old migration file", {
operation: "migration_cleanup_failed",
file: file.name,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} catch (error) {
databaseLogger.warn("Migration cleanup failed", {
operation: "migration_cleanup_error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}

View File

@@ -0,0 +1,118 @@
import { databaseLogger } from "./logger.js";
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static saveTimeout: NodeJS.Timeout | null = null;
static initialize(saveFunction: () => Promise<void>): void {
this.saveFunction = saveFunction;
this.isInitialized = true;
}
static async triggerSave(
reason: string = "data_modification",
): Promise<void> {
if (!this.isInitialized || !this.saveFunction) {
databaseLogger.warn("Database save trigger not initialized", {
operation: "db_save_trigger_not_init",
reason,
});
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(async () => {
if (this.pendingSave) {
return;
}
this.pendingSave = true;
try {
await this.saveFunction!();
} catch (error) {
databaseLogger.error("Database save failed", error, {
operation: "db_save_trigger_failed",
reason,
error: error instanceof Error ? error.message : "Unknown error",
});
} finally {
this.pendingSave = false;
}
}, 2000);
}
static async forceSave(reason: string = "critical_operation"): Promise<void> {
if (!this.isInitialized || !this.saveFunction) {
databaseLogger.warn(
"Database save trigger not initialized for force save",
{
operation: "db_save_trigger_force_not_init",
reason,
},
);
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
if (this.pendingSave) {
return;
}
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, {
operation: "db_save_trigger_force_failed",
reason,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
} finally {
this.pendingSave = false;
}
}
static getStatus(): {
initialized: boolean;
pendingSave: boolean;
hasPendingTimeout: boolean;
} {
return {
initialized: this.isInitialized,
pendingSave: this.pendingSave,
hasPendingTimeout: this.saveTimeout !== null,
};
}
static cleanup(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.pendingSave = false;
this.isInitialized = false;
this.saveFunction = null;
databaseLogger.info("Database save trigger cleaned up", {
operation: "db_save_trigger_cleanup",
});
}
}

View File

@@ -0,0 +1,108 @@
import crypto from "crypto";
interface EncryptedData {
data: string;
iv: string;
tag: string;
salt: string;
recordId: string;
}
class FieldCrypto {
private static readonly ALGORITHM = "aes-256-gcm";
private static readonly KEY_LENGTH = 32;
private static readonly IV_LENGTH = 16;
private static readonly SALT_LENGTH = 32;
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"password_hash",
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
]),
ssh_data: new Set(["password", "key", "keyPassword"]),
ssh_credentials: new Set([
"password",
"privateKey",
"keyPassword",
"key",
"publicKey",
]),
};
static encryptField(
plaintext: string,
masterKey: Buffer,
recordId: string,
fieldName: string,
): string {
if (!plaintext) return "";
const salt = crypto.randomBytes(this.SALT_LENGTH);
const context = `${recordId}:${fieldName}`;
const fieldKey = Buffer.from(
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
);
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag();
const encryptedData: EncryptedData = {
data: encrypted,
iv: iv.toString("hex"),
tag: tag.toString("hex"),
salt: salt.toString("hex"),
recordId: recordId,
};
return JSON.stringify(encryptedData);
}
static decryptField(
encryptedValue: string,
masterKey: Buffer,
recordId: string,
fieldName: string,
): string {
if (!encryptedValue) return "";
const encrypted: EncryptedData = JSON.parse(encryptedValue);
const salt = Buffer.from(encrypted.salt, "hex");
if (!encrypted.recordId) {
throw new Error(
`Encrypted field missing recordId context - data corruption or legacy format not supported`,
);
}
const context = `${encrypted.recordId}:${fieldName}`;
const fieldKey = Buffer.from(
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
);
const decipher = crypto.createDecipheriv(
this.ALGORITHM,
fieldKey,
Buffer.from(encrypted.iv, "hex"),
) as any;
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
static shouldEncryptField(tableName: string, fieldName: string): boolean {
const fields =
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
return fields ? fields.has(fieldName) : false;
}
}
export { FieldCrypto, type EncryptedData };

View File

@@ -0,0 +1,243 @@
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
export class LazyFieldEncryption {
static isPlaintextField(value: string): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
if (
parsed &&
typeof parsed === "object" &&
parsed.data &&
parsed.iv &&
parsed.tag &&
parsed.salt &&
parsed.recordId
) {
return false;
}
return true;
} catch (jsonError) {
return true;
}
}
static safeGetFieldValue(
fieldValue: string,
userKEK: Buffer,
recordId: string,
fieldName: string,
): string {
if (!fieldValue) return "";
if (this.isPlaintextField(fieldValue)) {
return fieldValue;
} else {
try {
const decrypted = FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
fieldName,
);
return decrypted;
} catch (error) {
databaseLogger.error("Failed to decrypt field", error, {
operation: "lazy_encryption_decrypt_failed",
recordId,
fieldName,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
}
}
static migrateFieldToEncrypted(
fieldValue: string,
userKEK: Buffer,
recordId: string,
fieldName: string,
): { encrypted: string; wasPlaintext: boolean } {
if (!fieldValue) {
return { encrypted: "", wasPlaintext: false };
}
if (this.isPlaintextField(fieldValue)) {
try {
const encrypted = FieldCrypto.encryptField(
fieldValue,
userKEK,
recordId,
fieldName,
);
return { encrypted, wasPlaintext: true };
} catch (error) {
databaseLogger.error("Failed to encrypt plaintext field", error, {
operation: "lazy_encryption_migrate_failed",
recordId,
fieldName,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
} else {
return { encrypted: fieldValue, wasPlaintext: false };
}
}
static migrateRecordSensitiveFields(
record: any,
sensitiveFields: string[],
userKEK: Buffer,
recordId: string,
): {
updatedRecord: any;
migratedFields: string[];
needsUpdate: boolean;
} {
const updatedRecord = { ...record };
const migratedFields: string[] = [];
let needsUpdate = false;
for (const fieldName of sensitiveFields) {
const fieldValue = record[fieldName];
if (fieldValue && this.isPlaintextField(fieldValue)) {
try {
const { encrypted } = this.migrateFieldToEncrypted(
fieldValue,
userKEK,
recordId,
fieldName,
);
updatedRecord[fieldName] = encrypted;
migratedFields.push(fieldName);
needsUpdate = true;
} catch (error) {
databaseLogger.error("Failed to migrate record field", error, {
operation: "lazy_encryption_record_field_failed",
recordId,
fieldName,
});
}
}
}
return { updatedRecord, migratedFields, needsUpdate };
}
static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record<string, string[]> = {
ssh_data: ["password", "key", "key_password"],
ssh_credentials: ["password", "key", "key_password", "private_key"],
users: ["totp_secret", "totp_backup_codes"],
};
return sensitiveFieldsMap[tableName] || [];
}
static async checkUserNeedsMigration(
userId: string,
userKEK: Buffer,
db: any,
): Promise<{
needsMigration: boolean;
plaintextFields: Array<{
table: string;
recordId: string;
fields: string[];
}>;
}> {
const plaintextFields: Array<{
table: string;
recordId: string;
fields: string[];
}> = [];
let needsMigration = false;
try {
const sshHosts = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
for (const host of sshHosts) {
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
const hostPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
if (host[field] && this.isPlaintextField(host[field])) {
hostPlaintextFields.push(field);
needsMigration = true;
}
}
if (hostPlaintextFields.length > 0) {
plaintextFields.push({
table: "ssh_data",
recordId: host.id.toString(),
fields: hostPlaintextFields,
});
}
}
const sshCredentials = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
for (const credential of sshCredentials) {
const sensitiveFields =
this.getSensitiveFieldsForTable("ssh_credentials");
const credentialPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
if (credential[field] && this.isPlaintextField(credential[field])) {
credentialPlaintextFields.push(field);
needsMigration = true;
}
}
if (credentialPlaintextFields.length > 0) {
plaintextFields.push({
table: "ssh_credentials",
recordId: credential.id.toString(),
fields: credentialPlaintextFields,
});
}
}
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
if (user) {
const sensitiveFields = this.getSensitiveFieldsForTable("users");
const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
if (user[field] && this.isPlaintextField(user[field])) {
userPlaintextFields.push(field);
needsMigration = true;
}
}
if (userPlaintextFields.length > 0) {
plaintextFields.push({
table: "users",
recordId: userId,
fields: userPlaintextFields,
});
}
}
return { needsMigration, plaintextFields };
} catch (error) {
databaseLogger.error("Failed to check user migration needs", error, {
operation: "lazy_encryption_user_check_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
return { needsMigration: false, plaintextFields: [] };
}
}
}

View File

@@ -14,10 +14,35 @@ export interface LogContext {
[key: string]: any;
}
const SENSITIVE_FIELDS = [
"password",
"passphrase",
"key",
"privateKey",
"publicKey",
"token",
"secret",
"clientSecret",
"keyPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"credentialId",
"authToken",
"jwt",
"session",
"cookie",
];
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
private logCounts = new Map<string, { count: number; lastLog: number }>();
private readonly RATE_LIMIT_WINDOW = 60000;
private readonly RATE_LIMIT_MAX = 10;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
@@ -29,6 +54,37 @@ class Logger {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
}
private sanitizeContext(context: LogContext): LogContext {
const sanitized = { ...context };
for (const field of SENSITIVE_FIELDS) {
if (sanitized[field] !== undefined) {
if (
typeof sanitized[field] === "string" &&
sanitized[field].length > 0
) {
sanitized[field] = "[MASKED]";
} else if (typeof sanitized[field] === "boolean") {
sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]";
} else {
sanitized[field] = "[MASKED]";
}
}
}
for (const field of TRUNCATE_FIELDS) {
if (
sanitized[field] &&
typeof sanitized[field] === "string" &&
sanitized[field].length > 100
) {
sanitized[field] = sanitized[field].substring(0, 100) + "...";
}
}
return sanitized;
}
private formatMessage(
level: LogLevel,
message: string,
@@ -41,14 +97,22 @@ class Logger {
let contextStr = "";
if (context) {
const sanitizedContext = this.sanitizeContext(context);
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (sanitizedContext.operation)
contextParts.push(`op:${sanitizedContext.operation}`);
if (sanitizedContext.userId)
contextParts.push(`user:${sanitizedContext.userId}`);
if (sanitizedContext.hostId)
contextParts.push(`host:${sanitizedContext.hostId}`);
if (sanitizedContext.tunnelName)
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
if (sanitizedContext.sessionId)
contextParts.push(`session:${sanitizedContext.sessionId}`);
if (sanitizedContext.requestId)
contextParts.push(`req:${sanitizedContext.requestId}`);
if (sanitizedContext.duration)
contextParts.push(`duration:${sanitizedContext.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
@@ -75,30 +139,49 @@ class Logger {
}
}
private shouldLog(level: LogLevel): boolean {
private shouldLog(level: LogLevel, message: string): boolean {
if (level === "debug" && process.env.NODE_ENV === "production") {
return false;
}
const now = Date.now();
const logKey = `${level}:${message}`;
const logInfo = this.logCounts.get(logKey);
if (logInfo) {
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
logInfo.count++;
if (logInfo.count > this.RATE_LIMIT_MAX) {
return false;
}
} else {
logInfo.count = 1;
logInfo.lastLog = now;
}
} else {
this.logCounts.set(logKey, { count: 1, lastLog: now });
}
return true;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog("debug")) return;
if (!this.shouldLog("debug", message)) return;
console.debug(this.formatMessage("debug", message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog("info")) return;
if (!this.shouldLog("info", message)) return;
console.log(this.formatMessage("info", message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog("warn")) return;
if (!this.shouldLog("warn", message)) return;
console.warn(this.formatMessage("warn", message, context));
}
error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog("error")) return;
if (!this.shouldLog("error", message)) return;
console.error(this.formatMessage("error", message, context));
if (error) {
console.error(error);
@@ -106,7 +189,7 @@ class Logger {
}
success(message: string, context?: LogContext): void {
if (!this.shouldLog("success")) return;
if (!this.shouldLog("success", message)) return;
console.log(this.formatMessage("success", message, context));
}

View File

@@ -0,0 +1,157 @@
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";
class SimpleDBOps {
static async insert<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
data: T,
userId: string,
): Promise<T> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = data.id || `temp-${userId}-${Date.now()}`;
const dataWithTempId = { ...data, id: tempId };
const encryptedData = DataCrypto.encryptRecord(
tableName,
dataWithTempId,
userId,
userDataKey,
);
if (!data.id) {
delete encryptedData.id;
}
const result = await getDb()
.insert(table)
.values(encryptedData)
.returning();
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result[0],
userId,
userDataKey,
);
return decryptedResult as T;
}
static async select<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return [];
}
const results = await query;
const decryptedResults = DataCrypto.decryptRecords(
tableName,
results,
userId,
userDataKey,
);
return decryptedResults;
}
static async selectOne<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T | undefined> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return undefined;
}
const result = await query;
if (!result) return undefined;
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result,
userId,
userDataKey,
);
return decryptedResult;
}
static async update<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
data: Partial<T>,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedData = DataCrypto.encryptRecord(
tableName,
data,
userId,
userDataKey,
);
const result = await getDb()
.update(table)
.set(encryptedData)
.where(where)
.returning();
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
const decryptedResults = DataCrypto.decryptRecords(
tableName,
result,
userId,
userDataKey,
);
return decryptedResults as T[];
}
static async delete(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
userId: string,
): Promise<any[]> {
const result = await getDb().delete(table).where(where).returning();
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
return result;
}
static async healthCheck(userId: string): Promise<boolean> {
return DataCrypto.canUserAccessData(userId);
}
static isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
static async selectEncrypted(
query: any,
tableName: TableName,
): Promise<any[]> {
const results = await query;
return results;
}
}
export { SimpleDBOps, type TableName };

View File

@@ -0,0 +1,418 @@
import ssh2Pkg from "ssh2";
const ssh2Utils = ssh2Pkg.utils;
function detectKeyTypeFromContent(keyContent: string): string {
const content = keyContent.trim();
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
if (
content.includes("ssh-ed25519") ||
content.includes("AAAAC3NzaC1lZDI1NTE5")
) {
return "ssh-ed25519";
}
if (content.includes("ssh-rsa") || content.includes("AAAAB3NzaC1yc2E")) {
return "ssh-rsa";
}
if (content.includes("ecdsa-sha2-nistp256")) {
return "ecdsa-sha2-nistp256";
}
if (content.includes("ecdsa-sha2-nistp384")) {
return "ecdsa-sha2-nistp384";
}
if (content.includes("ecdsa-sha2-nistp521")) {
return "ecdsa-sha2-nistp521";
}
try {
const base64Content = content
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
.replace("-----END OPENSSH PRIVATE KEY-----", "")
.replace(/\s/g, "");
const decoded = Buffer.from(base64Content, "base64").toString("binary");
if (decoded.includes("ssh-rsa")) {
return "ssh-rsa";
}
if (decoded.includes("ssh-ed25519")) {
return "ssh-ed25519";
}
if (decoded.includes("ecdsa-sha2-nistp256")) {
return "ecdsa-sha2-nistp256";
}
if (decoded.includes("ecdsa-sha2-nistp384")) {
return "ecdsa-sha2-nistp384";
}
if (decoded.includes("ecdsa-sha2-nistp521")) {
return "ecdsa-sha2-nistp521";
}
return "ssh-rsa";
} catch (error) {
return "ssh-rsa";
}
}
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
return "ssh-rsa";
}
if (content.includes("-----BEGIN DSA PRIVATE KEY-----")) {
return "ssh-dss";
}
if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
return "ecdsa-sha2-nistp256";
}
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
try {
const base64Content = content
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s/g, "");
const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString("binary");
if (decodedString.includes("1.2.840.113549.1.1.1")) {
return "ssh-rsa";
} else if (decodedString.includes("1.2.840.10045.2.1")) {
if (decodedString.includes("1.2.840.10045.3.1.7")) {
return "ecdsa-sha2-nistp256";
}
return "ecdsa-sha2-nistp256";
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {}
if (content.length < 800) {
return "ssh-ed25519";
} else if (content.length > 1600) {
return "ssh-rsa";
} else {
return "ecdsa-sha2-nistp256";
}
}
return "unknown";
}
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim();
if (content.startsWith("ssh-rsa ")) {
return "ssh-rsa";
}
if (content.startsWith("ssh-ed25519 ")) {
return "ssh-ed25519";
}
if (content.startsWith("ecdsa-sha2-nistp256 ")) {
return "ecdsa-sha2-nistp256";
}
if (content.startsWith("ecdsa-sha2-nistp384 ")) {
return "ecdsa-sha2-nistp384";
}
if (content.startsWith("ecdsa-sha2-nistp521 ")) {
return "ecdsa-sha2-nistp521";
}
if (content.startsWith("ssh-dss ")) {
return "ssh-dss";
}
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
try {
const base64Content = content
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(/\s/g, "");
const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString("binary");
if (decodedString.includes("1.2.840.113549.1.1.1")) {
return "ssh-rsa";
} else if (decodedString.includes("1.2.840.10045.2.1")) {
if (decodedString.includes("1.2.840.10045.3.1.7")) {
return "ecdsa-sha2-nistp256";
}
return "ecdsa-sha2-nistp256";
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch (error) {}
if (content.length < 400) {
return "ssh-ed25519";
} else if (content.length > 600) {
return "ssh-rsa";
} else {
return "ecdsa-sha2-nistp256";
}
}
if (content.includes("-----BEGIN RSA PUBLIC KEY-----")) {
return "ssh-rsa";
}
if (content.includes("AAAAB3NzaC1yc2E")) {
return "ssh-rsa";
}
if (content.includes("AAAAC3NzaC1lZDI1NTE5")) {
return "ssh-ed25519";
}
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY")) {
return "ecdsa-sha2-nistp256";
}
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ")) {
return "ecdsa-sha2-nistp384";
}
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE")) {
return "ecdsa-sha2-nistp521";
}
if (content.includes("AAAAB3NzaC1kc3M")) {
return "ssh-dss";
}
return "unknown";
}
export interface KeyInfo {
privateKey: string;
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface PublicKeyInfo {
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface KeyPairValidationResult {
isValid: boolean;
privateKeyType: string;
publicKeyType: string;
generatedPublicKey?: string;
error?: string;
}
export function parseSSHKey(
privateKeyData: string,
passphrase?: string,
): KeyInfo {
try {
let keyType = "unknown";
let publicKey = "";
let useSSH2 = false;
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
try {
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
if (!(parsedKey instanceof Error)) {
if (parsedKey.type) {
keyType = parsedKey.type;
}
try {
const publicKeyBuffer = parsedKey.getPublicSSH();
if (Buffer.isBuffer(publicKeyBuffer)) {
const base64Data = publicKeyBuffer.toString("base64");
if (keyType === "ssh-rsa") {
publicKey = `ssh-rsa ${base64Data}`;
} else if (keyType === "ssh-ed25519") {
publicKey = `ssh-ed25519 ${base64Data}`;
} else if (keyType.startsWith("ecdsa-")) {
publicKey = `${keyType} ${base64Data}`;
} else {
publicKey = `${keyType} ${base64Data}`;
}
} else {
publicKey = "";
}
} catch (error) {
publicKey = "";
}
useSSH2 = true;
}
} catch (error) {}
}
if (!useSSH2) {
keyType = detectKeyTypeFromContent(privateKeyData);
publicKey = "";
}
return {
privateKey: privateKeyData,
publicKey,
keyType,
success: keyType !== "unknown",
};
} catch (error) {
try {
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
if (fallbackKeyType !== "unknown") {
return {
privateKey: privateKeyData,
publicKey: "",
keyType: fallbackKeyType,
success: true,
};
}
} catch (fallbackError) {}
return {
privateKey: privateKeyData,
publicKey: "",
keyType: "unknown",
success: false,
error:
error instanceof Error ? error.message : "Unknown error parsing key",
};
}
}
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
try {
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
return {
publicKey: publicKeyData,
keyType,
success: keyType !== "unknown",
};
} catch (error) {
return {
publicKey: publicKeyData,
keyType: "unknown",
success: false,
error:
error instanceof Error
? error.message
: "Unknown error parsing public key",
};
}
}
export function detectKeyType(privateKeyData: string): string {
try {
const parsedKey = ssh2Utils.parseKey(privateKeyData);
if (parsedKey instanceof Error) {
return "unknown";
}
return parsedKey.type || "unknown";
} catch (error) {
return "unknown";
}
}
export function getFriendlyKeyTypeName(keyType: string): string {
const keyTypeMap: Record<string, string> = {
"ssh-rsa": "RSA",
"ssh-ed25519": "Ed25519",
"ecdsa-sha2-nistp256": "ECDSA P-256",
"ecdsa-sha2-nistp384": "ECDSA P-384",
"ecdsa-sha2-nistp521": "ECDSA P-521",
"ssh-dss": "DSA",
"rsa-sha2-256": "RSA-SHA2-256",
"rsa-sha2-512": "RSA-SHA2-512",
unknown: "Unknown",
};
return keyTypeMap[keyType] || keyType;
}
export function validateKeyPair(
privateKeyData: string,
publicKeyData: string,
passphrase?: string,
): KeyPairValidationResult {
try {
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
const publicKeyInfo = parsePublicKey(publicKeyData);
if (!privateKeyInfo.success) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Invalid private key: ${privateKeyInfo.error}`,
};
}
if (!publicKeyInfo.success) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Invalid public key: ${publicKeyInfo.error}`,
};
}
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`,
};
}
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
const generatedPublicKey = privateKeyInfo.publicKey.trim();
const providedPublicKey = publicKeyData.trim();
const generatedKeyParts = generatedPublicKey.split(" ");
const providedKeyParts = providedPublicKey.split(" ");
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
const generatedKeyData =
generatedKeyParts[0] + " " + generatedKeyParts[1];
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
if (generatedKeyData === providedKeyData) {
return {
isValid: true,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey,
};
} else {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey,
error: "Public key does not match the private key",
};
}
}
}
return {
isValid: true,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: "Unable to verify key pair match, but key types are compatible",
};
} catch (error) {
return {
isValid: false,
privateKeyType: "unknown",
publicKeyType: "unknown",
error:
error instanceof Error
? error.message
: "Unknown error during validation",
};
}
}

View File

@@ -0,0 +1,263 @@
import crypto from "crypto";
import { promises as fs } from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null;
private internalAuthToken: string | null = null;
private constructor() {}
static getInstance(): SystemCrypto {
if (!this.instance) {
this.instance = new SystemCrypto();
}
return this.instance;
}
async initializeJWTSecret(): Promise<void> {
try {
const envSecret = process.env.JWT_SECRET;
if (envSecret && envSecret.length >= 64) {
this.jwtSecret = envSecret;
return;
}
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
const envContent = await fs.readFile(envPath, "utf8");
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
this.jwtSecret = jwtMatch[1];
process.env.JWT_SECRET = jwtMatch[1];
return;
}
} catch {}
await this.generateAndGuideUser();
} catch (error) {
databaseLogger.error("Failed to initialize JWT secret", error, {
operation: "jwt_init_failed",
});
throw new Error("JWT secret initialization failed");
}
}
async getJWTSecret(): Promise<string> {
if (!this.jwtSecret) {
await this.initializeJWTSecret();
}
return this.jwtSecret!;
}
async initializeDatabaseKey(): Promise<void> {
try {
const envKey = process.env.DATABASE_KEY;
if (envKey && envKey.length >= 64) {
this.databaseKey = Buffer.from(envKey, "hex");
return;
}
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
const envContent = await fs.readFile(envPath, "utf8");
const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m);
if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) {
this.databaseKey = Buffer.from(dbKeyMatch[1], "hex");
process.env.DATABASE_KEY = dbKeyMatch[1];
return;
}
} catch {}
await this.generateAndGuideDatabaseKey();
} catch (error) {
databaseLogger.error("Failed to initialize database key", error, {
operation: "db_key_init_failed",
});
throw new Error("Database key initialization failed");
}
}
async getDatabaseKey(): Promise<Buffer> {
if (!this.databaseKey) {
await this.initializeDatabaseKey();
}
return this.databaseKey!;
}
async initializeInternalAuthToken(): Promise<void> {
try {
const envToken = process.env.INTERNAL_AUTH_TOKEN;
if (envToken && envToken.length >= 32) {
this.internalAuthToken = envToken;
return;
}
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
const envContent = await fs.readFile(envPath, "utf8");
const tokenMatch = envContent.match(/^INTERNAL_AUTH_TOKEN=(.+)$/m);
if (tokenMatch && tokenMatch[1] && tokenMatch[1].length >= 32) {
this.internalAuthToken = tokenMatch[1];
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
return;
}
} catch {}
await this.generateAndGuideInternalAuthToken();
} catch (error) {
databaseLogger.error("Failed to initialize internal auth token", error, {
operation: "internal_auth_init_failed",
});
throw new Error("Internal auth token initialization failed");
}
}
async getInternalAuthToken(): Promise<string> {
if (!this.internalAuthToken) {
await this.initializeInternalAuthToken();
}
return this.internalAuthToken!;
}
private async generateAndGuideUser(): Promise<void> {
const newSecret = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.jwtSecret = newSecret;
await this.updateEnvFile("JWT_SECRET", newSecret);
databaseLogger.success("JWT secret auto-generated and saved to .env", {
operation: "jwt_auto_generated",
instanceId,
envVarName: "JWT_SECRET",
note: "Ready for use - no restart required",
});
}
private async generateAndGuideDatabaseKey(): Promise<void> {
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.databaseKey = newKey;
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
databaseLogger.success("Database key auto-generated and saved to .env", {
operation: "db_key_auto_generated",
instanceId,
envVarName: "DATABASE_KEY",
note: "Ready for use - no restart required",
});
}
private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.internalAuthToken = newToken;
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
databaseLogger.success(
"Internal auth token auto-generated and saved to .env",
{
operation: "internal_auth_auto_generated",
instanceId,
envVarName: "INTERNAL_AUTH_TOKEN",
note: "Ready for use - no restart required",
},
);
}
async validateJWTSecret(): Promise<boolean> {
try {
const secret = await this.getJWTSecret();
if (!secret || secret.length < 32) {
return false;
}
const jwt = await import("jsonwebtoken");
const testPayload = { test: true, timestamp: Date.now() };
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
const decoded = jwt.default.verify(token, secret);
return !!decoded;
} catch (error) {
databaseLogger.error("JWT secret validation failed", error, {
operation: "jwt_validation_failed",
});
return false;
}
}
async getSystemKeyStatus() {
const isValid = await this.validateJWTSecret();
const hasSecret = this.jwtSecret !== null;
const hasEnvVar = !!(
process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64
);
return {
hasSecret,
isValid,
storage: {
environment: hasEnvVar,
},
algorithm: "HS256",
note: "Using simplified key management without encryption layers",
};
}
private async updateEnvFile(key: string, value: string): Promise<void> {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
await fs.mkdir(dataDir, { recursive: true });
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf8");
} catch {
envContent = "# Termix Auto-generated Configuration\n\n";
}
const keyRegex = new RegExp(`^${key}=.*$`, "m");
if (keyRegex.test(envContent)) {
envContent = envContent.replace(keyRegex, `${key}=${value}`);
} else {
if (!envContent.includes("# Security Keys")) {
envContent += "\n# Security Keys (Auto-generated)\n";
}
envContent += `${key}=${value}\n`;
}
await fs.writeFile(envPath, envContent);
process.env[key] = value;
} catch (error) {
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
operation: "env_file_update_failed",
key,
});
throw error;
}
}
}
export { SystemCrypto };

View File

@@ -0,0 +1,443 @@
import crypto from "crypto";
import { getDb } from "../database/db/index.js";
import { settings, users } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
interface KEKSalt {
salt: string;
iterations: number;
algorithm: string;
createdAt: string;
}
interface EncryptedDEK {
data: string;
iv: string;
tag: string;
algorithm: string;
createdAt: string;
}
interface UserSession {
dataKey: Buffer;
lastActivity: number;
expiresAt: number;
}
class UserCrypto {
private static instance: UserCrypto;
private userSessions: Map<string, UserSession> = new Map();
private sessionExpiredCallback?: (userId: string) => void;
private static readonly PBKDF2_ITERATIONS = 100000;
private static readonly KEK_LENGTH = 32;
private static readonly DEK_LENGTH = 32;
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
private constructor() {
setInterval(
() => {
this.cleanupExpiredSessions();
},
5 * 60 * 1000,
);
}
static getInstance(): UserCrypto {
if (!this.instance) {
this.instance = new UserCrypto();
}
return this.instance;
}
setSessionExpiredCallback(callback: (userId: string) => void): void {
this.sessionExpiredCallback = callback;
}
async setupUserEncryption(userId: string, password: string): Promise<void> {
const kekSalt = await this.generateKEKSalt();
await this.storeKEKSalt(userId, kekSalt);
const KEK = this.deriveKEK(password, kekSalt);
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const encryptedDEK = this.encryptDEK(DEK, KEK);
await this.storeEncryptedDEK(userId, encryptedDEK);
KEK.fill(0);
DEK.fill(0);
}
async setupOIDCUserEncryption(userId: string): Promise<void> {
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const now = Date.now();
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
}
async authenticateUser(userId: string, password: string): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
const KEK = this.deriveKEK(password, kekSalt);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
KEK.fill(0);
return false;
}
const DEK = this.decryptDEK(encryptedDEK, KEK);
KEK.fill(0);
if (!DEK || DEK.length === 0) {
databaseLogger.error("DEK is empty or invalid after decryption", {
operation: "user_crypto_auth_debug",
userId,
dekLength: DEK ? DEK.length : 0,
});
return false;
}
const now = Date.now();
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
return true;
} catch (error) {
databaseLogger.warn("User authentication failed", {
operation: "user_crypto_auth_failed",
userId,
error: error instanceof Error ? error.message : "Unknown",
});
return false;
}
}
async authenticateOIDCUser(userId: string): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) {
await this.setupOIDCUserEncryption(userId);
return true;
}
const systemKey = this.deriveOIDCSystemKey(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
systemKey.fill(0);
await this.setupOIDCUserEncryption(userId);
return true;
}
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);
if (!DEK || DEK.length === 0) {
await this.setupOIDCUserEncryption(userId);
return true;
}
const now = Date.now();
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
return true;
} catch (error) {
await this.setupOIDCUserEncryption(userId);
return true;
}
}
getUserDataKey(userId: string): Buffer | null {
const session = this.userSessions.get(userId);
if (!session) {
return null;
}
const now = Date.now();
if (now > session.expiresAt) {
this.userSessions.delete(userId);
session.dataKey.fill(0);
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null;
}
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
this.userSessions.delete(userId);
session.dataKey.fill(0);
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null;
}
session.lastActivity = now;
return session.dataKey;
}
logoutUser(userId: string): void {
const session = this.userSessions.get(userId);
if (session) {
session.dataKey.fill(0);
this.userSessions.delete(userId);
}
}
isUserUnlocked(userId: string): boolean {
return this.getUserDataKey(userId) !== null;
}
async changeUserPassword(
userId: string,
oldPassword: string,
newPassword: string,
): Promise<boolean> {
try {
const isValid = await this.validatePassword(userId, oldPassword);
if (!isValid) return false;
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
const oldKEK = this.deriveKEK(oldPassword, kekSalt);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) return false;
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
const newKekSalt = await this.generateKEKSalt();
const newKEK = this.deriveKEK(newPassword, newKekSalt);
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
oldKEK.fill(0);
newKEK.fill(0);
DEK.fill(0);
this.logoutUser(userId);
return true;
} catch (error) {
return false;
}
}
private async validatePassword(
userId: string,
password: string,
): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
const KEK = this.deriveKEK(password, kekSalt);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) return false;
const DEK = this.decryptDEK(encryptedDEK, KEK);
KEK.fill(0);
DEK.fill(0);
return true;
} catch (error) {
return false;
}
}
private cleanupExpiredSessions(): void {
const now = Date.now();
const expiredUsers: string[] = [];
for (const [userId, session] of this.userSessions.entries()) {
if (
now > session.expiresAt ||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
) {
session.dataKey.fill(0);
expiredUsers.push(userId);
}
}
expiredUsers.forEach((userId) => {
this.userSessions.delete(userId);
});
}
private async generateKEKSalt(): Promise<KEKSalt> {
return {
salt: crypto.randomBytes(32).toString("hex"),
iterations: UserCrypto.PBKDF2_ITERATIONS,
algorithm: "pbkdf2-sha256",
createdAt: new Date().toISOString(),
};
}
private deriveKEK(password: string, kekSalt: KEKSalt): Buffer {
return crypto.pbkdf2Sync(
password,
Buffer.from(kekSalt.salt, "hex"),
kekSalt.iterations,
UserCrypto.KEK_LENGTH,
"sha256",
);
}
private deriveOIDCSystemKey(userId: string): Buffer {
const systemSecret =
process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default";
const salt = Buffer.from(userId, "utf8");
return crypto.pbkdf2Sync(
systemSecret,
salt,
100000,
UserCrypto.KEK_LENGTH,
"sha256",
);
}
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
let encrypted = cipher.update(dek);
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag();
return {
data: encrypted.toString("hex"),
iv: iv.toString("hex"),
tag: tag.toString("hex"),
algorithm: "aes-256-gcm",
createdAt: new Date().toISOString(),
};
}
private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
kek,
Buffer.from(encryptedDEK.iv, "hex"),
);
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex"));
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted;
}
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
const key = `user_kek_salt_${userId}`;
const value = JSON.stringify(kekSalt);
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
}
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
try {
const key = `user_kek_salt_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (result.length === 0) {
return null;
}
return JSON.parse(result[0].value);
} catch (error) {
return null;
}
}
private async storeEncryptedDEK(
userId: string,
encryptedDEK: EncryptedDEK,
): Promise<void> {
const key = `user_encrypted_dek_${userId}`;
const value = JSON.stringify(encryptedDEK);
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
}
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
try {
const key = `user_encrypted_dek_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (result.length === 0) {
return null;
}
return JSON.parse(result[0].value);
} catch (error) {
return null;
}
}
}
export { UserCrypto, type KEKSalt, type EncryptedDEK };

View File

@@ -0,0 +1,281 @@
import { getDb } from "../database/db/index.js";
import {
users,
sshData,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
} from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
interface UserExportData {
version: string;
exportedAt: string;
userId: string;
username: string;
userData: {
sshHosts: any[];
sshCredentials: any[];
fileManagerData: {
recent: any[];
pinned: any[];
shortcuts: any[];
};
dismissedAlerts: any[];
};
metadata: {
totalRecords: number;
encrypted: boolean;
exportType: "user_data" | "system_config" | "all";
};
}
class UserDataExport {
private static readonly EXPORT_VERSION = "v2.0";
static async exportUserData(
userId: string,
options: {
format?: "encrypted" | "plaintext";
scope?: "user_data" | "all";
includeCredentials?: boolean;
} = {},
): Promise<UserExportData> {
const {
format = "encrypted",
scope = "user_data",
includeCredentials = true,
} = options;
try {
const user = await getDb()
.select()
.from(users)
.where(eq(users.id, userId));
if (!user || user.length === 0) {
throw new Error(`User not found: ${userId}`);
}
const userRecord = user[0];
let userDataKey: Buffer | null = null;
if (format === "plaintext") {
userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error(
"User data not unlocked - password required for plaintext export",
);
}
}
const sshHosts = await getDb()
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const processedSshHosts =
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
)
: sshHosts;
let sshCredentialsData: any[] = [];
if (includeCredentials) {
const credentials = await getDb()
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
sshCredentialsData =
format === "plaintext" && userDataKey
? credentials.map((cred) =>
DataCrypto.decryptRecord(
"ssh_credentials",
cred,
userId,
userDataKey!,
),
)
: credentials;
}
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
getDb()
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId)),
getDb()
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId)),
getDb()
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)),
]);
const alerts = await getDb()
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const exportData: UserExportData = {
version: this.EXPORT_VERSION,
exportedAt: new Date().toISOString(),
userId: userRecord.id,
username: userRecord.username,
userData: {
sshHosts: processedSshHosts,
sshCredentials: sshCredentialsData,
fileManagerData: {
recent: recentFiles,
pinned: pinnedFiles,
shortcuts: shortcuts,
},
dismissedAlerts: alerts,
},
metadata: {
totalRecords:
processedSshHosts.length +
sshCredentialsData.length +
recentFiles.length +
pinnedFiles.length +
shortcuts.length +
alerts.length,
encrypted: format === "encrypted",
exportType: scope,
},
};
databaseLogger.success("User data export completed", {
operation: "user_data_export_complete",
userId,
totalRecords: exportData.metadata.totalRecords,
format,
sshHosts: processedSshHosts.length,
sshCredentials: sshCredentialsData.length,
});
return exportData;
} catch (error) {
databaseLogger.error("User data export failed", error, {
operation: "user_data_export_failed",
userId,
format,
scope,
});
throw error;
}
}
static async exportUserDataToJSON(
userId: string,
options: {
format?: "encrypted" | "plaintext";
scope?: "user_data" | "all";
includeCredentials?: boolean;
pretty?: boolean;
} = {},
): Promise<string> {
const { pretty = true } = options;
const exportData = await this.exportUserData(userId, options);
return JSON.stringify(exportData, null, pretty ? 2 : 0);
}
static validateExportData(data: any): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!data || typeof data !== "object") {
errors.push("Export data must be an object");
return { valid: false, errors };
}
if (!data.version) {
errors.push("Missing version field");
}
if (!data.userId) {
errors.push("Missing userId field");
}
if (!data.userData || typeof data.userData !== "object") {
errors.push("Missing or invalid userData field");
}
if (!data.metadata || typeof data.metadata !== "object") {
errors.push("Missing or invalid metadata field");
}
if (data.userData) {
const requiredFields = [
"sshHosts",
"sshCredentials",
"fileManagerData",
"dismissedAlerts",
];
for (const field of requiredFields) {
if (
!Array.isArray(data.userData[field]) &&
!(
field === "fileManagerData" &&
typeof data.userData[field] === "object"
)
) {
errors.push(`Missing or invalid userData.${field} field`);
}
}
if (
data.userData.fileManagerData &&
typeof data.userData.fileManagerData === "object"
) {
const fmFields = ["recent", "pinned", "shortcuts"];
for (const field of fmFields) {
if (!Array.isArray(data.userData.fileManagerData[field])) {
errors.push(
`Missing or invalid userData.fileManagerData.${field} field`,
);
}
}
}
}
return { valid: errors.length === 0, errors };
}
static getExportStats(data: UserExportData): {
version: string;
exportedAt: string;
username: string;
totalRecords: number;
breakdown: {
sshHosts: number;
sshCredentials: number;
fileManagerItems: number;
dismissedAlerts: number;
};
encrypted: boolean;
} {
return {
version: data.version,
exportedAt: data.exportedAt,
username: data.username,
totalRecords: data.metadata.totalRecords,
breakdown: {
sshHosts: data.userData.sshHosts.length,
sshCredentials: data.userData.sshCredentials.length,
fileManagerItems:
data.userData.fileManagerData.recent.length +
data.userData.fileManagerData.pinned.length +
data.userData.fileManagerData.shortcuts.length,
dismissedAlerts: data.userData.dismissedAlerts.length,
},
encrypted: data.metadata.encrypted,
};
}
}
export { UserDataExport, type UserExportData };

View File

@@ -0,0 +1,434 @@
import { getDb } from "../database/db/index.js";
import {
users,
sshData,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
} from "../database/db/schema.js";
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;
skipCredentials?: boolean;
skipFileManagerData?: boolean;
dryRun?: boolean;
}
interface ImportResult {
success: boolean;
summary: {
sshHostsImported: number;
sshCredentialsImported: number;
fileManagerItemsImported: number;
dismissedAlertsImported: number;
skippedItems: number;
errors: string[];
};
dryRun: boolean;
}
class UserDataImport {
static async importUserData(
targetUserId: string,
exportData: UserExportData,
options: ImportOptions = {},
): Promise<ImportResult> {
const {
replaceExisting = false,
skipCredentials = false,
skipFileManagerData = false,
dryRun = false,
} = options;
try {
const targetUser = await getDb()
.select()
.from(users)
.where(eq(users.id, targetUserId));
if (!targetUser || targetUser.length === 0) {
throw new Error(`Target user not found: ${targetUserId}`);
}
const validation = UserDataExport.validateExportData(exportData);
if (!validation.valid) {
throw new Error(`Invalid export data: ${validation.errors.join(", ")}`);
}
let userDataKey: Buffer | null = null;
if (exportData.metadata.encrypted) {
userDataKey = DataCrypto.getUserDataKey(targetUserId);
if (!userDataKey) {
throw new Error(
"Target user data not unlocked - password required for encrypted import",
);
}
}
const result: ImportResult = {
success: false,
summary: {
sshHostsImported: 0,
sshCredentialsImported: 0,
fileManagerItemsImported: 0,
dismissedAlertsImported: 0,
skippedItems: 0,
errors: [],
},
dryRun,
};
if (
exportData.userData.sshHosts &&
exportData.userData.sshHosts.length > 0
) {
const importStats = await this.importSshHosts(
targetUserId,
exportData.userData.sshHosts,
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshHostsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
if (
!skipCredentials &&
exportData.userData.sshCredentials &&
exportData.userData.sshCredentials.length > 0
) {
const importStats = await this.importSshCredentials(
targetUserId,
exportData.userData.sshCredentials,
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshCredentialsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
if (!skipFileManagerData && exportData.userData.fileManagerData) {
const importStats = await this.importFileManagerData(
targetUserId,
exportData.userData.fileManagerData,
{ replaceExisting, dryRun },
);
result.summary.fileManagerItemsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
if (
exportData.userData.dismissedAlerts &&
exportData.userData.dismissedAlerts.length > 0
) {
const importStats = await this.importDismissedAlerts(
targetUserId,
exportData.userData.dismissedAlerts,
{ replaceExisting, dryRun },
);
result.summary.dismissedAlertsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
result.success = result.summary.errors.length === 0;
databaseLogger.success("User data import completed", {
operation: "user_data_import_complete",
targetUserId,
dryRun,
...result.summary,
});
return result;
} catch (error) {
databaseLogger.error("User data import failed", error, {
operation: "user_data_import_failed",
targetUserId,
dryRun,
});
throw error;
}
}
private static async importSshHosts(
targetUserId: string,
sshHosts: any[],
options: {
replaceExisting: boolean;
dryRun: boolean;
userDataKey: Buffer | null;
},
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const host of sshHosts) {
try {
if (options.dryRun) {
imported++;
continue;
}
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
const newHostData = {
...host,
id: tempId,
userId: targetUserId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
let processedHostData = newHostData;
if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord(
"ssh_data",
newHostData,
targetUserId,
options.userDataKey,
);
}
delete processedHostData.id;
await getDb().insert(sshData).values(processedHostData);
imported++;
} catch (error) {
errors.push(
`SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
return { imported, skipped, errors };
}
private static async importSshCredentials(
targetUserId: string,
credentials: any[],
options: {
replaceExisting: boolean;
dryRun: boolean;
userDataKey: Buffer | null;
},
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const credential of credentials) {
try {
if (options.dryRun) {
imported++;
continue;
}
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
const newCredentialData = {
...credential,
id: tempCredId,
userId: targetUserId,
usageCount: 0,
lastUsed: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
let processedCredentialData = newCredentialData;
if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord(
"ssh_credentials",
newCredentialData,
targetUserId,
options.userDataKey,
);
}
delete processedCredentialData.id;
await getDb().insert(sshCredentials).values(processedCredentialData);
imported++;
} catch (error) {
errors.push(
`SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
return { imported, skipped, errors };
}
private static async importFileManagerData(
targetUserId: string,
fileManagerData: any,
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
try {
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
for (const item of fileManagerData.recent) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
lastOpened: new Date().toISOString(),
};
await getDb().insert(fileManagerRecent).values(newItem);
}
imported++;
} catch (error) {
errors.push(
`Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
}
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
for (const item of fileManagerData.pinned) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
pinnedAt: new Date().toISOString(),
};
await getDb().insert(fileManagerPinned).values(newItem);
}
imported++;
} catch (error) {
errors.push(
`Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
}
if (
fileManagerData.shortcuts &&
Array.isArray(fileManagerData.shortcuts)
) {
for (const item of fileManagerData.shortcuts) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
createdAt: new Date().toISOString(),
};
await getDb().insert(fileManagerShortcuts).values(newItem);
}
imported++;
} catch (error) {
errors.push(
`Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
}
} catch (error) {
errors.push(
`File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return { imported, skipped, errors };
}
private static async importDismissedAlerts(
targetUserId: string,
alerts: any[],
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const alert of alerts) {
try {
if (options.dryRun) {
imported++;
continue;
}
const existing = await getDb()
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, targetUserId),
eq(dismissedAlerts.alertId, alert.alertId),
),
);
if (existing.length > 0 && !options.replaceExisting) {
skipped++;
continue;
}
const newAlert = {
...alert,
id: undefined,
userId: targetUserId,
dismissedAt: new Date().toISOString(),
};
if (existing.length > 0 && options.replaceExisting) {
await getDb()
.update(dismissedAlerts)
.set(newAlert)
.where(eq(dismissedAlerts.id, existing[0].id));
} else {
await getDb().insert(dismissedAlerts).values(newAlert);
}
imported++;
} catch (error) {
errors.push(
`Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
return { imported, skipped, errors };
}
static async importUserDataFromJSON(
targetUserId: string,
jsonData: string,
options: ImportOptions = {},
): Promise<ImportResult> {
try {
const exportData: UserExportData = JSON.parse(jsonData);
return await this.importUserData(targetUserId, exportData, options);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("Invalid JSON format in import data");
}
throw error;
}
}
}
export { UserDataImport, type ImportOptions, type ImportResult };

View File

@@ -1,8 +1,40 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { Toaster as Sonner, type ToasterProps, toast } from "sonner";
import { useRef } from "react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const lastToastRef = useRef<{ text: string; timestamp: number } | null>(null);
const originalToast = toast;
const rateLimitedToast = (message: string, options?: any) => {
const now = Date.now();
const lastToast = lastToastRef.current;
if (
lastToast &&
lastToast.text === message &&
now - lastToast.timestamp < 1000
) {
return;
}
lastToastRef.current = { text: message, timestamp: now };
return originalToast(message, options);
};
Object.assign(toast, {
success: (message: string, options?: any) =>
rateLimitedToast(message, { ...options, type: "success" }),
error: (message: string, options?: any) =>
rateLimitedToast(message, { ...options, type: "error" }),
warning: (message: string, options?: any) =>
rateLimitedToast(message, { ...options, type: "warning" }),
info: (message: string, options?: any) =>
rateLimitedToast(message, { ...options, type: "info" }),
message: rateLimitedToast,
});
return (
<Sonner

View File

@@ -0,0 +1,109 @@
import React from "react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx";
import { ExternalLink, Download, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VersionAlertProps {
updateInfo: {
success: boolean;
status?: "up_to_date" | "requires_update";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name: string;
name: string;
published_at: string;
html_url: string;
body: string;
};
cached?: boolean;
cache_age?: number;
error?: string;
};
onDownload?: () => void;
}
export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) {
const { t } = useTranslation();
if (!updateInfo.success) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.error")}</AlertTitle>
<AlertDescription>
{updateInfo.error || t("versionCheck.checkFailed")}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "up_to_date") {
return (
<Alert>
<Download className="h-4 w-4" />
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
<AlertDescription>
{t("versionCheck.currentVersion", {
version: updateInfo.localVersion,
})}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "requires_update") {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.updateAvailable")}</AlertTitle>
<AlertDescription className="space-y-3">
<div>
{t("versionCheck.newVersionAvailable", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</div>
{updateInfo.latest_release && (
<div className="text-sm text-muted-foreground">
<div className="font-medium">
{updateInfo.latest_release.name}
</div>
<div className="text-xs">
{t("versionCheck.releasedOn", {
date: new Date(
updateInfo.latest_release.published_at,
).toLocaleDateString(),
})}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
{updateInfo.latest_release?.html_url && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (onDownload) {
onDownload();
} else {
window.open(updateInfo.latest_release!.html_url, "_blank");
}
}}
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t("versionCheck.downloadUpdate")}
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
return null;
}

View File

@@ -0,0 +1,187 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { VersionAlert } from "@/components/ui/version-alert.tsx";
import { RefreshCw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { checkElectronUpdate, isElectron } from "@/ui/main-axios.ts";
interface VersionCheckModalProps {
onDismiss: () => void;
onContinue: () => void;
isAuthenticated?: boolean;
}
export function VersionCheckModal({
onDismiss,
onContinue,
isAuthenticated = false,
}: VersionCheckModalProps) {
const { t } = useTranslation();
const [versionInfo, setVersionInfo] = useState<any>(null);
const [versionChecking, setVersionChecking] = useState(false);
const [versionDismissed, setVersionDismissed] = useState(false);
useEffect(() => {
if (isElectron()) {
checkForUpdates();
} else {
onContinue();
}
}, []);
const checkForUpdates = async () => {
setVersionChecking(true);
try {
const updateInfo = await checkElectronUpdate();
setVersionInfo(updateInfo);
if (updateInfo?.status === "up_to_date") {
onContinue();
return;
}
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo({ success: false, error: "Check failed" });
} finally {
setVersionChecking(false);
}
};
const handleVersionDismiss = () => {
setVersionDismissed(true);
};
const handleDownloadUpdate = () => {
if (versionInfo?.latest_release?.html_url) {
window.open(versionInfo.latest_release.html_url, "_blank");
}
};
const handleContinue = () => {
onContinue();
};
if (!isElectron()) {
return null;
}
if (versionChecking && !versionInfo) {
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
{!isAuthenticated && (
<div
className="absolute inset-0"
style={{
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",
}}
/>
)}
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="flex items-center justify-center mb-4">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
<p className="text-center text-muted-foreground">
{t("versionCheck.checkingUpdates")}
</p>
</div>
</div>
);
}
if (!versionInfo || versionDismissed) {
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
{!isAuthenticated && (
<div
className="absolute inset-0"
style={{
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",
}}
/>
)}
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="mb-4">
<h2 className="text-lg font-semibold">
{t("versionCheck.checkUpdates")}
</h2>
</div>
{versionInfo && !versionDismissed && (
<div className="mb-4">
<VersionAlert
updateInfo={versionInfo}
onDownload={handleDownloadUpdate}
/>
</div>
)}
<div className="flex gap-2">
<Button onClick={handleContinue} className="flex-1 h-10">
{t("common.continue")}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
{!isAuthenticated && (
<div
className="absolute inset-0"
style={{
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",
}}
/>
)}
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="mb-4">
<h2 className="text-lg font-semibold">
{t("versionCheck.updateRequired")}
</h2>
</div>
<div className="mb-4">
<VersionAlert
updateInfo={versionInfo}
onDownload={handleDownloadUpdate}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleContinue} className="flex-1 h-10">
{t("common.continue")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));

View File

@@ -87,7 +87,20 @@
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
"uploadKeyFile": "Upload Key File",
"generateKeyPairButton": "Generate Key Pair",
"generateKeyPair": "Generate Key Pair",
"generateKeyPairDescription": "Generate a new SSH key pair. If you want to protect the key with a passphrase, enter it in the Key Password field below first.",
"deploySSHKey": "Deploy SSH Key",
"deploySSHKeyDescription": "Deploy public key to target server",
"sourceCredential": "Source Credential",
"targetHost": "Target Host",
"deploymentProcess": "Deployment Process",
"deploymentProcessDescription": "This will safely add the public key to the target host's ~/.ssh/authorized_keys file without overwriting existing keys. The operation is reversible.",
"chooseHostToDeploy": "Choose a host to deploy to...",
"deploying": "Deploying...",
"name": "Name",
"noHostsAvailable": "No hosts available",
"noHostsMatchSearch": "No hosts match your search",
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
"connectionTestingNotImplemented": "Connection testing feature coming soon",
"testConnection": "Test Connection",
@@ -123,14 +136,47 @@
"editCredentialDescription": "Update the credential information",
"listView": "List",
"folderView": "Folders",
"unknown": "Unknown",
"unknownCredential": "Unknown",
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
"failedToRemoveFromFolder": "Failed to remove credential from folder",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move credential to folder"
"failedToMoveToFolder": "Failed to move credential to folder",
"sshPublicKey": "SSH Public Key",
"publicKeyNote": "Public key is optional but recommended for key validation",
"publicKeyUploaded": "Public Key Uploaded",
"uploadPublicKey": "Upload Public Key",
"uploadPrivateKeyFile": "Upload Private Key File",
"uploadPublicKeyFile": "Upload Public Key File",
"privateKeyRequiredForGeneration": "Private key is required to generate public key",
"failedToGeneratePublicKey": "Failed to generate public key",
"generatePublicKey": "Generate from Private Key",
"publicKeyGeneratedSuccessfully": "Public key generated successfully",
"detectedKeyType": "Detected key type",
"detectingKeyType": "detecting...",
"optional": "Optional",
"generateKeyPairNew": "Generate New Key Pair",
"generateEd25519": "Generate Ed25519",
"generateECDSA": "Generate ECDSA",
"generateRSA": "Generate RSA",
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
"failedToGenerateKeyPair": "Failed to generate key pair",
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form.",
"invalidKey": "Invalid Key",
"detectionError": "Detection Error",
"unknown": "Unknown"
},
"dragIndicator": {
"error": "Error: {{error}}",
"dragging": "Dragging {{fileName}}",
"preparing": "Preparing {{fileName}}",
"readySingle": "Ready to download {{fileName}}",
"readyMultiple": "Ready to download {{count}} files",
"batchDrag": "Drag {{count}} files to desktop",
"dragToDesktop": "Drag to desktop",
"canDragAnywhere": "You can drag files anywhere on your desktop"
},
"sshTools": {
"title": "SSH Tools",
@@ -167,12 +213,32 @@
"saveError": "Error saving configuration",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 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)"
},
"versionCheck": {
"error": "Version Check Error",
"checkFailed": "Failed to check for updates",
"upToDate": "App is Up to Date",
"currentVersion": "You are running version {{version}}",
"updateAvailable": "Update Available",
"newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.",
"releasedOn": "Released on {{date}}",
"downloadUpdate": "Download Update",
"dismiss": "Dismiss",
"checking": "Checking for updates...",
"checkUpdates": "Check for Updates",
"checkingUpdates": "Checking for updates...",
"refresh": "Refresh",
"updateRequired": "Update Required",
"updateDismissed": "Update notification dismissed",
"noUpdatesFound": "No updates found"
},
"common": {
"close": "Close",
"minimize": "Minimize",
"online": "Online",
"offline": "Offline",
"continue": "Continue",
"maintenance": "Maintenance",
"degraded": "Degraded",
"discord": "Discord",
@@ -201,6 +267,7 @@
"newVersionAvailable": "A new version ({{version}}) is available.",
"failedToFetchUpdateInfo": "Failed to fetch update information",
"preRelease": "Pre-release",
"loginFailed": "Login failed",
"noReleasesFound": "No releases found.",
"yourBackupCodes": "Your Backup Codes",
"sendResetCode": "Send Reset Code",
@@ -219,6 +286,9 @@
"sshTools": "SSH Tools",
"english": "English",
"chinese": "Chinese",
"cancel": "Cancel",
"username": "Username",
"name": "Name",
"login": "Login",
"logout": "Logout",
"register": "Register",
@@ -270,7 +340,10 @@
"failedToInitiatePasswordReset": "Failed to initiate password reset",
"failedToVerifyResetCode": "Failed to verify reset code",
"failedToCompletePasswordReset": "Failed to complete password reset",
"documentation": "Documentation"
"documentation": "Documentation",
"retry": "Retry",
"checking": "Checking...",
"checkingDatabase": "Checking database connection..."
},
"nav": {
"home": "Home",
@@ -353,7 +426,126 @@
"deleteUser": "Delete user {{username}}? This cannot be undone.",
"userDeletedSuccessfully": "User {{username}} deleted successfully",
"failedToDeleteUser": "Failed to delete user",
"overrideUserInfoUrl": "Override User Info URL (not required)"
"overrideUserInfoUrl": "Override User Info URL (not required)",
"databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled",
"enabled": "Enabled",
"disabled": "Disabled",
"keyId": "Key ID",
"created": "Created",
"migrationStatus": "Migration Status",
"migrationCompleted": "Migration completed",
"migrationRequired": "Migration required",
"deviceProtectedMasterKey": "Environment-Protected Master Key",
"legacyKeyStorage": "Legacy Key Storage",
"masterKeyEncryptedWithDeviceFingerprint": "Master key encrypted with environment fingerprint (KEK protection active)",
"keyNotProtectedByDeviceBinding": "Key not protected by environment binding (upgrade recommended)",
"valid": "Valid",
"initializeDatabaseEncryption": "Initialize Database Encryption",
"enableAes256EncryptionWithDeviceBinding": "Enable AES-256 encryption with environment-bound master key protection. This creates enterprise-grade security for SSH keys, passwords, and authentication tokens.",
"featuresEnabled": "Features enabled:",
"aes256GcmAuthenticatedEncryption": "AES-256-GCM authenticated encryption",
"deviceFingerprintMasterKeyProtection": "Environment fingerprint master key protection (KEK)",
"pbkdf2KeyDerivation": "PBKDF2 key derivation with 100K iterations",
"automaticKeyManagement": "Automatic key management and rotation",
"initializing": "Initializing...",
"initializeEnterpriseEncryption": "Initialize Enterprise Encryption",
"migrateExistingData": "Migrate Existing Data",
"encryptExistingUnprotectedData": "Encrypt existing unprotected data in your database. This process is safe and creates automatic backups.",
"testMigrationDryRun": "Verify Encryption Compatibility",
"migrating": "Migrating...",
"migrateData": "Migrate Data",
"securityInformation": "Security Information",
"sshPrivateKeysEncryptedWithAes256": "SSH private keys and passwords are encrypted with AES-256-GCM",
"userAuthTokensProtected": "User authentication tokens and 2FA secrets are protected",
"masterKeysProtectedByDeviceFingerprint": "Master encryption keys are protected by device fingerprint (KEK)",
"keysBoundToServerInstance": "Keys are bound to current server environment (migratable via environment variables)",
"pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF key derivation with 100K iterations",
"backwardCompatibleMigration": "All data remains backward compatible during migration",
"enterpriseGradeSecurityActive": "Enterprise-Grade Security Active",
"masterKeysProtectedByDeviceBinding": "Your master encryption keys are protected by environment fingerprinting. This uses server hostname, paths, and other environment info to generate protection keys. To migrate servers, set the DB_ENCRYPTION_KEY environment variable on the new server.",
"important": "Important",
"keepEncryptionKeysSecure": "Ensure data security: regularly backup your database files and server configuration. To migrate to a new server, set the DB_ENCRYPTION_KEY environment variable on the new environment, or maintain the same hostname and directory structure.",
"loadingEncryptionStatus": "Loading encryption status...",
"testMigrationDescription": "Verify that existing data can be safely migrated to encrypted format without actually modifying any data",
"serverMigrationGuide": "Server Migration Guide",
"migrationInstructions": "To migrate encrypted data to a new server: 1) Backup database files, 2) Set environment variable DB_ENCRYPTION_KEY=\"your-key\" on new server, 3) Restore database files",
"environmentProtection": "Environment Protection",
"environmentProtectionDesc": "Protects encryption keys based on server environment info (hostname, paths, etc.), migratable via environment variables",
"verificationCompleted": "Compatibility verification completed - no data was changed",
"verificationInProgress": "Verification completed",
"dataMigrationCompleted": "Data migration completed successfully!",
"migrationCompleted": "Migration completed",
"verificationFailed": "Compatibility verification failed",
"migrationFailed": "Migration failed",
"runningVerification": "Running compatibility verification...",
"startingMigration": "Starting migration...",
"hardwareFingerprintSecurity": "Hardware Fingerprint Security",
"hardwareBoundEncryption": "Hardware-Bound Encryption Active",
"masterKeysNowProtectedByHardwareFingerprint": "Master keys are now protected by real hardware fingerprinting instead of environment variables",
"cpuSerialNumberDetection": "CPU serial number detection",
"motherboardUuidIdentification": "Motherboard UUID identification",
"diskSerialNumberVerification": "Disk serial number verification",
"biosSerialNumberCheck": "BIOS serial number check",
"stableMacAddressFiltering": "Stable MAC address filtering",
"databaseFileEncryption": "Database File Encryption",
"dualLayerProtection": "Dual-Layer Protection Active",
"bothFieldAndFileEncryptionActive": "Both field-level and file-level encryption are now active for maximum security",
"fieldLevelAes256Encryption": "Field-level AES-256 encryption for sensitive data",
"fileLevelDatabaseEncryption": "File-level database encryption with hardware binding",
"hardwareBoundFileKeys": "Hardware-bound file encryption keys",
"automaticEncryptedBackups": "Automatic encrypted backup creation",
"createEncryptedBackup": "Create Encrypted Backup",
"creatingBackup": "Creating Backup...",
"backupCreated": "Backup Created",
"encryptedBackupCreatedSuccessfully": "Encrypted backup created successfully",
"backupCreationFailed": "Backup creation failed",
"databaseMigration": "Database Migration",
"exportForMigration": "Export for Migration",
"exportDatabaseForHardwareMigration": "Export database as SQLite file with decrypted data for migration to new hardware",
"exportDatabase": "Export SQLite Database",
"exporting": "Exporting...",
"exportCreated": "SQLite Export Created",
"exportContainsDecryptedData": "SQLite export contains decrypted data - keep secure!",
"databaseExportedSuccessfully": "SQLite database exported successfully",
"databaseExportFailed": "SQLite database export failed",
"importFromMigration": "Import from Migration",
"importDatabaseFromAnotherSystem": "Import SQLite database from another system or hardware",
"importDatabase": "Import SQLite Database",
"importing": "Importing...",
"selectedFile": "Selected SQLite File",
"importWillReplaceExistingData": "SQLite import will replace existing data - backup recommended!",
"pleaseSelectImportFile": "Please select a SQLite import file",
"databaseImportedSuccessfully": "SQLite database imported successfully",
"databaseImportFailed": "SQLite database import failed",
"manageEncryptionAndBackups": "Manage encryption keys, database security, and backup operations",
"activeSecurityFeatures": "Currently active security measures and protections",
"deviceBindingTechnology": "Advanced hardware-based key protection technology",
"backupAndRecovery": "Secure backup creation and database recovery options",
"crossSystemDataTransfer": "Export and import databases across different systems",
"noMigrationNeeded": "No migration needed",
"encryptionKey": "Encryption Key",
"keyProtection": "Key Protection",
"active": "Active",
"legacy": "Legacy",
"dataStatus": "Data Status",
"encrypted": "Encrypted",
"needsMigration": "Needs Migration",
"ready": "Ready",
"initializeEncryption": "Initialize Encryption",
"initialize": "Initialize",
"test": "Test",
"migrate": "Migrate",
"backup": "Backup",
"createBackup": "Create Backup",
"exportImport": "Export/Import",
"export": "Export",
"import": "Import",
"passwordRequired": "Password required",
"confirmExport": "Confirm Export",
"exportDescription": "Export SSH hosts and credentials as SQLite file",
"importDescription": "Import SQLite file with incremental merge (skips duplicates)"
},
"hosts": {
"title": "Host Manager",
@@ -398,6 +590,7 @@
"mustSelectValidSshConfig": "Must select a valid SSH configuration from the list",
"addHost": "Add Host",
"editHost": "Edit Host",
"cloneHost": "Clone Host",
"updateHost": "Update Host",
"hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!",
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
@@ -429,6 +622,8 @@
"sshpassRequired": "Sshpass Required For Password Authentication",
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
"otherInstallMethods": "Other installation methods:",
"debianUbuntuEquivalent": "(Debian/Ubuntu) or the equivalent for your OS.",
"or": "or",
"centosRhelFedora": "CentOS/RHEL/Fedora",
"macos": "macOS",
"windows": "Windows",
@@ -510,7 +705,10 @@
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
"reconnected": "Reconnected successfully",
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
"connectionTimeout": "Connection timeout"
"connectionTimeout": "Connection timeout",
"terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
"runTitle": "Running {{command}} - {{host}}"
},
"fileManager": {
"title": "File Manager",
@@ -518,6 +716,14 @@
"folder": "Folder",
"connectToSsh": "Connect to SSH to use file operations",
"uploadFile": "Upload File",
"downloadFile": "Download",
"edit": "Edit",
"preview": "Preview",
"previous": "Previous",
"next": "Next",
"pageXOfY": "Page {{current}} of {{total}}",
"zoomOut": "Zoom Out",
"zoomIn": "Zoom In",
"newFile": "New File",
"newFolder": "New Folder",
"rename": "Rename",
@@ -525,12 +731,15 @@
"deleteItem": "Delete Item",
"currentPath": "Current Path",
"uploadFileTitle": "Upload File",
"maxFileSize": "Max: 100MB (JSON) / 200MB (Binary)",
"maxFileSize": "Max: 1GB (JSON) / 5GB (Binary) - Large files supported",
"removeFile": "Remove File",
"clickToSelectFile": "Click to select a file",
"chooseFile": "Choose File",
"uploading": "Uploading...",
"downloading": "Downloading...",
"uploadingFile": "Uploading {{name}}...",
"uploadingLargeFile": "Uploading large file {{name}} ({{size}})...",
"downloadingFile": "Downloading {{name}}...",
"creatingFile": "Creating {{name}}...",
"creatingFolder": "Creating {{name}}...",
"deletingItem": "Deleting {{type}} {{name}}...",
@@ -552,11 +761,46 @@
"renaming": "Renaming...",
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
"failedToUploadFile": "Failed to upload file",
"fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully",
"failedToDownloadFile": "Failed to download file",
"noFileContent": "No file content received",
"filePath": "File Path",
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
"failedToCreateFile": "Failed to create file",
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
"failedToCreateFolder": "Failed to create folder",
"failedToCreateItem": "Failed to create item",
"operationFailed": "{{operation}} operation failed for {{name}}: {{error}}",
"failedToResolveSymlink": "Failed to resolve symlink",
"itemDeletedSuccessfully": "{{type}} deleted successfully",
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
"failedToDeleteItems": "Failed to delete items",
"dragFilesToUpload": "Drop files here to upload",
"emptyFolder": "This folder is empty",
"itemCount": "{{count}} items",
"selectedCount": "{{count}} selected",
"searchFiles": "Search files...",
"upload": "Upload",
"selectHostToStart": "Select a host to start file management",
"failedToConnect": "Failed to connect to SSH",
"failedToLoadDirectory": "Failed to load directory",
"noSSHConnection": "No SSH connection available",
"enterFolderName": "Enter folder name:",
"enterFileName": "Enter file name:",
"copy": "Copy",
"cut": "Cut",
"paste": "Paste",
"delete": "Delete",
"properties": "Properties",
"preview": "Preview",
"refresh": "Refresh",
"downloadFiles": "Download {{count}} files to Browser",
"copyFiles": "Copy {{count}} items",
"cutFiles": "Cut {{count}} items",
"deleteFiles": "Delete {{count}} items",
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
"filesCutToClipboard": "{{count}} items cut to clipboard",
"movedItems": "Moved {{count}} items",
"failedToDeleteItem": "Failed to delete item",
"itemRenamedSuccessfully": "{{type}} renamed successfully",
"failedToRenameItem": "Failed to rename item",
@@ -583,7 +827,7 @@
"serverError": "Server Error",
"error": "Error",
"requestFailed": "Request failed with status code",
"unknown": "unknown",
"unknownFileError": "unknown",
"cannotReadFile": "Cannot read file",
"noSshSessionId": "No SSH session ID available",
"noFilePath": "No file path available",
@@ -617,7 +861,124 @@
"sshStatusCheckTimeout": "SSH status check timed out",
"sshReconnectionTimeout": "SSH reconnection timed out",
"saveOperationTimeout": "Save operation timed out",
"cannotSaveFile": "Cannot save file"
"cannotSaveFile": "Cannot save file",
"dragSystemFilesToUpload": "Drag system files here to upload",
"dragFilesToWindowToDownload": "Drag files outside window to download",
"openTerminalHere": "Open Terminal Here",
"run": "Run",
"saveToSystem": "Save as...",
"selectLocationToSave": "Select Location to Save",
"openTerminalInFolder": "Open Terminal in This Folder",
"openTerminalInFileLocation": "Open Terminal at File Location",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
"runningFile": "Running - {{file}}",
"onlyRunExecutableFiles": "Can only run executable files",
"noHostSelected": "No host selected",
"starred": "Starred",
"shortcuts": "Shortcuts",
"directories": "Directories",
"removedFromRecentFiles": "Removed \"{{name}}\" from recent files",
"removeFailed": "Remove failed",
"unpinnedSuccessfully": "Unpinned \"{{name}}\" successfully",
"unpinFailed": "Unpin failed",
"removedShortcut": "Removed shortcut \"{{name}}\"",
"removeShortcutFailed": "Remove shortcut failed",
"clearedAllRecentFiles": "Cleared all recent files",
"clearFailed": "Clear failed",
"removeFromRecentFiles": "Remove from recent files",
"clearAllRecentFiles": "Clear all recent files",
"unpinFile": "Unpin file",
"removeShortcut": "Remove shortcut",
"saveFilesToSystem": "Save {{count}} files as...",
"saveToSystem": "Save as...",
"pinFile": "Pin file",
"addToShortcuts": "Add to shortcuts",
"selectLocationToSave": "Select location to save",
"downloadToDefaultLocation": "Download to default location",
"pasteFailed": "Paste failed",
"noUndoableActions": "No undoable actions",
"undoCopySuccess": "Undid copy operation: Deleted {{count}} copied files",
"undoCopyFailedDelete": "Undo failed: Could not delete any copied files",
"undoCopyFailedNoInfo": "Undo failed: Could not find copied file information",
"undoMoveSuccess": "Undid move operation: Moved {{count}} files back to original location",
"undoMoveFailedMove": "Undo failed: Could not move any files back",
"undoMoveFailedNoInfo": "Undo failed: Could not find moved file information",
"undoDeleteNotSupported": "Delete operation cannot be undone: Files have been permanently deleted from server",
"undoTypeNotSupported": "Unsupported undo operation type",
"undoOperationFailed": "Undo operation failed",
"unknownError": "Unknown error",
"enterPath": "Enter path...",
"editPath": "Edit path",
"confirm": "Confirm",
"cancel": "Cancel",
"folderName": "Folder name",
"find": "Find...",
"replaceWith": "Replace with...",
"replace": "Replace",
"replaceAll": "Replace All",
"downloadInstead": "Download Instead",
"keyboardShortcuts": "Keyboard Shortcuts",
"searchAndReplace": "Search & Replace",
"editing": "Editing",
"navigation": "Navigation",
"code": "Code",
"search": "Search",
"findNext": "Find Next",
"findPrevious": "Find Previous",
"save": "Save",
"selectAll": "Select All",
"undo": "Undo",
"redo": "Redo",
"goToLine": "Go to Line",
"moveLineUp": "Move Line Up",
"moveLineDown": "Move Line Down",
"toggleComment": "Toggle Comment",
"indent": "Indent",
"outdent": "Outdent",
"autoComplete": "Auto Complete",
"imageLoadError": "Failed to load image",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"rotate": "Rotate",
"originalSize": "Original Size",
"startTyping": "Start typing...",
"unknownSize": "Unknown size",
"fileIsEmpty": "File is empty",
"modified": "Modified",
"largeFileWarning": "Large File Warning",
"largeFileWarningDesc": "This file is {{size}} in size, which may cause performance issues when opened as text.",
"fileNotFoundAndRemoved": "File \"{{name}}\" not found and has been removed from recent/pinned files",
"failedToLoadFile": "Failed to load file: {{error}}",
"serverErrorOccurred": "Server error occurred. Please try again later.",
"fileSavedSuccessfully": "File saved successfully",
"autoSaveFailed": "Auto-save failed",
"fileAutoSaved": "File auto-saved",
"fileDownloadedSuccessfully": "File downloaded successfully",
"moveFileFailed": "Failed to move {{name}}",
"moveOperationFailed": "Move operation failed",
"canOnlyCompareFiles": "Can only compare two files",
"comparingFiles": "Comparing files: {{file1}} and {{file2}}",
"dragFailed": "Drag operation failed",
"filePinnedSuccessfully": "File \"{{name}}\" pinned successfully",
"pinFileFailed": "Failed to pin file",
"fileUnpinnedSuccessfully": "File \"{{name}}\" unpinned successfully",
"unpinFileFailed": "Failed to unpin file",
"shortcutAddedSuccessfully": "Folder shortcut \"{{name}}\" added successfully",
"addShortcutFailed": "Failed to add shortcut",
"operationCompletedSuccessfully": "{{operation}} {{count}} items successfully",
"operationCompleted": "{{operation}} {{count}} items",
"downloadFileSuccess": "File {{name}} downloaded successfully",
"downloadFileFailed": "Download failed",
"moveTo": "Move to {{name}}",
"diffCompareWith": "Diff compare with {{name}}",
"dragOutsideToDownload": "Drag outside window to download ({{count}} files)",
"newFolderDefault": "NewFolder",
"newFileDefault": "NewFile.txt",
"successfullyMovedItems": "Successfully moved {{count}} items to {{target}}",
"move": "Move",
"searchInFile": "Search in file (Ctrl+F)",
"showKeyboardShortcuts": "Show keyboard shortcuts",
"startWritingMarkdown": "Start writing your markdown content..."
},
"tunnels": {
"title": "SSH Tunnels",
@@ -627,6 +988,7 @@
"disconnected": "Disconnected",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"unknownTunnelStatus": "Unknown",
"unknown": "Unknown",
"error": "Error",
"failed": "Failed",
@@ -664,7 +1026,7 @@
"dynamic": "Dynamic",
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
"unknown": "Unknown",
"unknownConnectionStatus": "Unknown",
"connected": "Connected",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
@@ -673,7 +1035,10 @@
"disconnect": "Disconnect",
"connect": "Connect",
"canceling": "Canceling...",
"endpointHostNotFound": "Endpoint host not found"
"endpointHostNotFound": "Endpoint host not found",
"discord": "Discord",
"githubIssue": "GitHub issue",
"forHelp": "for help"
},
"serverStats": {
"title": "Server Statistics",
@@ -782,7 +1147,7 @@
"enableTwoFactorButton": "Enable Two-Factor Authentication",
"addExtraSecurityLayer": "Add an extra layer of security to your account",
"firstUser": "First User",
"firstUserMessage": "You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown. If you think this is a mistake, check the docker logs, or create a",
"firstUserMessage": "You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown. If you think this is a mistake, check the docker logs, or create a GitHub issue.",
"external": "External",
"loginWithExternal": "Login with External Provider",
"loginWithExternalDesc": "Login using your configured external identity provider",
@@ -807,8 +1172,9 @@
"forbidden": "Access forbidden",
"serverError": "Server error",
"networkError": "Network error",
"databaseConnection": "Could not connect to the database. Please try again later.",
"databaseConnection": "Could not connect to the database.",
"unknownError": "Unknown error",
"loginFailed": "Login failed",
"failedPasswordReset": "Failed to initiate password reset",
"failedVerifyCode": "Failed to verify reset code",
"failedCompleteReset": "Failed to complete password reset",
@@ -828,7 +1194,8 @@
"usernameExists": "Username already exists",
"emailExists": "Email already exists",
"loadFailed": "Failed to load data",
"saveError": "Failed to save"
"saveError": "Failed to save",
"sessionExpired": "Session expired - please log in again"
},
"messages": {
"saveSuccess": "Saved successfully",
@@ -845,7 +1212,15 @@
"reconnecting": "Reconnecting...",
"processing": "Processing...",
"pleaseWait": "Please wait...",
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator."
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
"databaseConnected": "Database connected successfully",
"databaseConnectionFailed": "Failed to connect to the database server",
"checkServerConnection": "Please check your server connection and try again",
"resetCodeSent": "Reset code sent to Docker logs",
"codeVerified": "Code verified successfully",
"passwordResetSuccess": "Password reset successfully",
"loginSuccess": "Login successful",
"registrationSuccess": "Registration successful"
},
"profile": {
"title": "User Profile",
@@ -878,6 +1253,7 @@
"password": "password",
"keyPassword": "key password",
"pastePrivateKey": "Paste your private key here...",
"pastePublicKey": "Paste your public key here...",
"credentialName": "My SSH Server",
"description": "SSH credential description",
"searchCredentials": "Search credentials by name, username, or tags...",
@@ -1007,6 +1383,9 @@
"updateKey": "Update Key",
"productionFolder": "Production",
"databaseServer": "Database Server",
"developmentServer": "Development Server",
"developmentFolder": "Development",
"webServerProduction": "Web Server - Production",
"unknownError": "Unknown error",
"failedToInitiatePasswordReset": "Failed to initiate password reset",
"failedToVerifyResetCode": "Failed to verify reset code",
@@ -1030,6 +1409,10 @@
},
"mobile": {
"selectHostToStart": "Select a host to start your terminal session",
"limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience."
"limitedSupportMessage": "Website mobile support is still in progress. Use the mobile app for a better experience.",
"mobileAppInProgress": "Mobile app is in progress",
"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"
}
}

View File

@@ -87,7 +87,20 @@
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
"leaveEmptyToKeepCurrent": "留空以保持当前值",
"uploadKeyFile": "上传密钥文件",
"generateKeyPairButton": "生成密钥对",
"generateKeyPair": "生成密钥对",
"generateKeyPairDescription": "生成新的SSH密钥对。如果您想用密码保护密钥请先在下面的密钥密码字段中输入密码。",
"deploySSHKey": "部署SSH密钥",
"deploySSHKeyDescription": "将公钥部署到目标服务器",
"sourceCredential": "源凭据",
"targetHost": "目标主机",
"deploymentProcess": "部署过程",
"deploymentProcessDescription": "这将安全地将公钥添加到目标主机的~/.ssh/authorized_keys文件中而不会覆盖现有密钥。此操作是可逆的。",
"chooseHostToDeploy": "选择要部署到的主机...",
"deploying": "部署中...",
"name": "名称",
"noHostsAvailable": "没有可用的主机",
"noHostsMatchSearch": "没有匹配搜索的主机",
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
"connectionTestingNotImplemented": "连接测试功能即将推出",
"testConnection": "测试连接",
@@ -122,14 +135,46 @@
"editCredentialDescription": "更新凭据信息",
"listView": "列表",
"folderView": "文件夹",
"unknown": "未知",
"unknownCredential": "未知",
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动凭据到文件夹失败"
"failedToMoveToFolder": "移动凭据到文件夹失败",
"sshPublicKey": "SSH公钥",
"publicKeyNote": "公钥是可选的,但建议提供以验证密钥对",
"publicKeyUploaded": "公钥已上传",
"uploadPublicKey": "上传公钥",
"uploadPrivateKeyFile": "上传私钥文件",
"uploadPublicKeyFile": "上传公钥文件",
"privateKeyRequiredForGeneration": "生成公钥需要先输入私钥",
"failedToGeneratePublicKey": "生成公钥失败",
"generatePublicKey": "从私钥生成",
"publicKeyGeneratedSuccessfully": "公钥生成成功",
"detectedKeyType": "检测到的密钥类型",
"detectingKeyType": "检测中...",
"optional": "可选",
"generateKeyPairNew": "生成新的密钥对",
"generateEd25519": "生成 Ed25519",
"generateECDSA": "生成 ECDSA",
"generateRSA": "生成 RSA",
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
"failedToGenerateKeyPair": "生成密钥对失败",
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
"invalidKey": "无效密钥",
"detectionError": "检测错误"
},
"dragIndicator": {
"error": "错误:{{error}}",
"dragging": "正在拖拽 {{fileName}}",
"preparing": "正在准备 {{fileName}}",
"readySingle": "准备下载 {{fileName}}",
"readyMultiple": "准备下载 {{count}} 个文件",
"batchDrag": "拖拽 {{count}} 个文件到桌面",
"dragToDesktop": "拖拽到桌面",
"canDragAnywhere": "您可以将文件拖拽到桌面的任何位置"
},
"sshTools": {
"title": "SSH 工具",
@@ -166,12 +211,32 @@
"saveError": "保存配置时出错",
"saving": "保存中...",
"saveConfig": "保存配置",
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:8081 或 https://your-server.com"
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:30001 或 https://your-server.com"
},
"versionCheck": {
"error": "版本检查错误",
"checkFailed": "检查更新失败",
"upToDate": "应用已是最新版本",
"currentVersion": "您正在运行版本 {{version}}",
"updateAvailable": "有可用更新",
"newVersionAvailable": "有新版本可用!您正在运行 {{current}},但 {{latest}} 已可用。",
"releasedOn": "发布于 {{date}}",
"downloadUpdate": "下载更新",
"dismiss": "忽略",
"checking": "正在检查更新...",
"checkUpdates": "检查更新",
"checkingUpdates": "正在检查更新...",
"refresh": "刷新",
"updateRequired": "需要更新",
"updateDismissed": "更新通知已忽略",
"noUpdatesFound": "未找到更新"
},
"common": {
"close": "关闭",
"minimize": "最小化",
"online": "在线",
"offline": "离线",
"continue": "继续",
"maintenance": "维护中",
"degraded": "降级",
"discord": "Discord",
@@ -199,6 +264,7 @@
"newVersionAvailable": "有新版本 ({{version}}) 可用。",
"failedToFetchUpdateInfo": "获取更新信息失败",
"preRelease": "预发布版本",
"loginFailed": "登录失败",
"noReleasesFound": "未找到发布版本。",
"yourBackupCodes": "您的备份代码",
"sendResetCode": "发送重置代码",
@@ -214,6 +280,9 @@
"sshTools": "SSH 工具",
"english": "英语",
"chinese": "中文",
"cancel": "取消",
"username": "用户名",
"name": "名称",
"login": "登录",
"logout": "登出",
"register": "注册",
@@ -257,7 +326,10 @@
"failedToInitiatePasswordReset": "启动密码重置失败",
"failedToVerifyResetCode": "验证重置代码失败",
"failedToCompletePasswordReset": "完成密码重置失败",
"documentation": "文档"
"documentation": "文档",
"retry": "重试",
"checking": "检查中...",
"checkingDatabase": "正在检查数据库连接..."
},
"nav": {
"home": "首页",
@@ -339,7 +411,125 @@
"failedToRemoveAdminStatus": "移除管理员权限失败",
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
"failedToDeleteUser": "删除用户失败",
"overrideUserInfoUrl": "覆盖用户信息 URL非必填"
"overrideUserInfoUrl": "覆盖用户信息 URL非必填",
"databaseSecurity": "数据库安全",
"encryptionStatus": "加密状态",
"encryptionEnabled": "加密已启用",
"enabled": "已启用",
"disabled": "已禁用",
"keyId": "密钥 ID",
"created": "创建时间",
"migrationStatus": "迁移状态",
"migrationCompleted": "迁移完成",
"migrationRequired": "需要迁移",
"deviceProtectedMasterKey": "环境保护主密钥",
"legacyKeyStorage": "传统密钥存储",
"masterKeyEncryptedWithDeviceFingerprint": "主密钥已通过环境指纹加密KEK 保护已激活)",
"keyNotProtectedByDeviceBinding": "密钥未受环境绑定保护(建议升级)",
"valid": "有效",
"initializeDatabaseEncryption": "初始化数据库加密",
"enableAes256EncryptionWithDeviceBinding": "启用具有环境绑定主密钥保护的 AES-256 加密。这为 SSH 密钥、密码和身份验证令牌创建企业级安全保护。",
"featuresEnabled": "启用的功能:",
"aes256GcmAuthenticatedEncryption": "AES-256-GCM 认证加密",
"deviceFingerprintMasterKeyProtection": "环境指纹主密钥保护 (KEK)",
"pbkdf2KeyDerivation": "PBKDF2 密钥推导10万次迭代",
"automaticKeyManagement": "自动密钥管理和轮换",
"initializing": "初始化中...",
"initializeEnterpriseEncryption": "初始化企业级加密",
"migrateExistingData": "迁移现有数据",
"encryptExistingUnprotectedData": "加密数据库中现有的未保护数据。此过程安全可靠,会自动创建备份。",
"testMigrationDryRun": "验证加密兼容性",
"migrating": "迁移中...",
"migrateData": "迁移数据",
"securityInformation": "安全信息",
"sshPrivateKeysEncryptedWithAes256": "SSH 私钥和密码使用 AES-256-GCM 加密",
"userAuthTokensProtected": "用户认证令牌和 2FA 密钥受到保护",
"masterKeysProtectedByDeviceFingerprint": "主加密密钥受设备指纹保护 (KEK)",
"keysBoundToServerInstance": "密钥绑定到当前服务器环境(可通过环境变量迁移)",
"pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF 密钥推导10万次迭代",
"backwardCompatibleMigration": "迁移过程中所有数据保持向后兼容",
"enterpriseGradeSecurityActive": "企业级安全已激活",
"masterKeysProtectedByDeviceBinding": "您的主加密密钥受环境指纹保护。这基于服务器的主机名、路径等环境信息生成保护密钥。如需迁移服务器,可通过设置 DB_ENCRYPTION_KEY 环境变量来实现数据迁移。",
"important": "重要提示",
"keepEncryptionKeysSecure": "确保数据安全:定期备份数据库文件和服务器配置。如需迁移到新服务器,请在新环境中设置 DB_ENCRYPTION_KEY 环境变量,或保持相同的主机名和目录结构。",
"loadingEncryptionStatus": "正在加载加密状态...",
"testMigrationDescription": "验证现有数据是否可以安全地迁移到加密格式,不会实际修改任何数据",
"serverMigrationGuide": "服务器迁移指南",
"migrationInstructions": "要将加密数据迁移到新服务器1) 备份数据库文件2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的密钥\"3) 恢复数据库文件",
"environmentProtection": "环境保护",
"environmentProtectionDesc": "基于服务器环境信息(主机名、路径等)保护加密密钥,可通过环境变量实现迁移",
"verificationCompleted": "兼容性验证完成 - 未修改任何数据",
"verificationInProgress": "验证完成",
"dataMigrationCompleted": "数据迁移完成!",
"verificationFailed": "兼容性验证失败",
"migrationFailed": "迁移失败",
"runningVerification": "正在进行兼容性验证...",
"startingMigration": "开始迁移...",
"hardwareFingerprintSecurity": "硬件指纹安全",
"hardwareBoundEncryption": "硬件绑定加密已激活",
"masterKeysNowProtectedByHardwareFingerprint": "主密钥现在受真实硬件指纹保护,而非环境变量",
"cpuSerialNumberDetection": "CPU 序列号检测",
"motherboardUuidIdentification": "主板 UUID 识别",
"diskSerialNumberVerification": "磁盘序列号验证",
"biosSerialNumberCheck": "BIOS 序列号检查",
"stableMacAddressFiltering": "稳定 MAC 地址过滤",
"databaseFileEncryption": "数据库文件加密",
"dualLayerProtection": "双层保护已激活",
"bothFieldAndFileEncryptionActive": "字段级和文件级加密现均已激活,提供最大安全保护",
"fieldLevelAes256Encryption": "敏感数据的字段级 AES-256 加密",
"fileLevelDatabaseEncryption": "硬件绑定的文件级数据库加密",
"hardwareBoundFileKeys": "硬件绑定的文件加密密钥",
"automaticEncryptedBackups": "自动加密备份创建",
"createEncryptedBackup": "创建加密备份",
"creatingBackup": "创建备份中...",
"backupCreated": "备份已创建",
"encryptedBackupCreatedSuccessfully": "加密备份创建成功",
"backupCreationFailed": "备份创建失败",
"databaseMigration": "数据库迁移",
"exportForMigration": "导出用于迁移",
"exportDatabaseForHardwareMigration": "导出 SQLite 格式的解密数据库以迁移到新硬件",
"exportDatabase": "导出 SQLite 数据库",
"exporting": "导出中...",
"exportCreated": "SQLite 导出已创建",
"exportContainsDecryptedData": "SQLite 导出包含解密数据 - 请保持安全!",
"databaseExportedSuccessfully": "SQLite 数据库导出成功",
"databaseExportFailed": "SQLite 数据库导出失败",
"importFromMigration": "从迁移导入",
"importDatabaseFromAnotherSystem": "从其他系统或硬件导入 SQLite 数据库",
"importDatabase": "导入 SQLite 数据库",
"importing": "导入中...",
"selectedFile": "选定 SQLite 文件",
"importWillReplaceExistingData": "SQLite 导入将替换现有数据 - 建议备份!",
"pleaseSelectImportFile": "请选择 SQLite 导入文件",
"databaseImportedSuccessfully": "SQLite 数据库导入成功",
"databaseImportFailed": "SQLite 数据库导入失败",
"manageEncryptionAndBackups": "管理加密密钥、数据库安全和备份操作",
"activeSecurityFeatures": "当前活跃的安全措施和保护功能",
"deviceBindingTechnology": "高级硬件密钥保护技术",
"backupAndRecovery": "安全备份创建和数据库恢复选项",
"crossSystemDataTransfer": "跨系统数据库导出和导入",
"noMigrationNeeded": "无需迁移",
"encryptionKey": "加密密钥",
"keyProtection": "密钥保护",
"active": "已激活",
"legacy": "旧版",
"dataStatus": "数据状态",
"encrypted": "已加密",
"needsMigration": "需要迁移",
"ready": "就绪",
"initializeEncryption": "初始化加密",
"initialize": "初始化",
"test": "测试",
"migrate": "迁移",
"backup": "备份",
"createBackup": "创建备份",
"exportImport": "导出/导入",
"export": "导出",
"import": "导入",
"passwordRequired": "密码为必填项",
"confirmExport": "确认导出",
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
"importDescription": "导入SQLite文件并进行增量合并跳过重复项"
},
"hosts": {
"title": "主机管理",
@@ -384,6 +574,7 @@
"mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置",
"addHost": "添加主机",
"editHost": "编辑主机",
"cloneHost": "克隆主机",
"deleteHost": "删除主机",
"authType": "认证类型",
"passwordAuth": "密码",
@@ -451,11 +642,21 @@
"maxRetriesDescription": "隧道连接的最大重试次数。",
"retryIntervalDescription": "重试尝试之间的等待时间。",
"otherInstallMethods": "其他安装方法:",
"debianUbuntuEquivalent": "(Debian/Ubuntu) 或您的操作系统的等效命令。",
"or": "或",
"centosRhelFedora": "CentOS/RHEL/Fedora",
"macos": "macOS",
"windows": "Windows",
"sshpassOSInstructions": {
"centos": "CentOS/RHEL/Fedora: sudo yum install sshpass 或 sudo dnf install sshpass",
"macos": "macOS: brew install hudochenkov/sshpass/sshpass",
"windows": "Windows: 使用 WSL 或考虑使用 SSH 密钥认证"
},
"sshServerConfigRequired": "SSH 服务器配置要求",
"sshServerConfigDesc": "对于隧道连接SSH 服务器必须配置允许端口转发:",
"gatewayPortsYes": "绑定远程端口到所有接口",
"allowTcpForwardingYes": "启用端口转发",
"permitRootLoginYes": "如果使用 root 用户进行隧道连接",
"sshServerConfigReverse": "对于反向 SSH 隧道,端点 SSH 服务器必须允许:",
"gatewayPorts": "GatewayPorts yes绑定远程端口",
"allowTcpForwarding": "AllowTcpForwarding yes端口转发",
@@ -498,6 +699,9 @@
},
"terminal": {
"title": "终端",
"terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}",
"connect": "连接主机",
"disconnect": "断开连接",
"clear": "清屏",
@@ -533,6 +737,14 @@
"folder": "文件夹",
"connectToSsh": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"downloadFile": "下载",
"edit": "编辑",
"preview": "预览",
"previous": "上一页",
"next": "下一页",
"pageXOfY": "第 {{current}} 页,共 {{total}} 页",
"zoomOut": "缩小",
"zoomIn": "放大",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
@@ -540,12 +752,15 @@
"deleteItem": "删除项目",
"currentPath": "当前路径",
"uploadFileTitle": "上传文件",
"maxFileSize": "最大100MBJSON/ 200MB二进制",
"maxFileSize": "最大1GBJSON/ 5GB二进制- 支持大文件",
"removeFile": "移除文件",
"clickToSelectFile": "点击选择文件",
"chooseFile": "选择文件",
"uploading": "上传中...",
"downloading": "下载中...",
"uploadingFile": "正在上传 {{name}}...",
"uploadingLargeFile": "正在上传大文件 {{name}} ({{size}})...",
"downloadingFile": "正在下载 {{name}}...",
"creatingFile": "正在创建 {{name}}...",
"creatingFolder": "正在创建 {{name}}...",
"deletingItem": "正在删除 {{type}} {{name}}...",
@@ -567,21 +782,52 @@
"renaming": "重命名中...",
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
"failedToUploadFile": "上传文件失败",
"failedToDownloadFile": "下载文件失败",
"noFileContent": "未收到文件内容",
"filePath": "文件路径",
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
"failedToCreateFile": "创建文件失败",
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
"failedToCreateFolder": "创建文件夹失败",
"failedToCreateItem": "创建项目失败",
"operationFailed": "{{operation}} 操作失败,文件 {{name}}{{error}}",
"failedToResolveSymlink": "解析符号链接失败",
"itemDeletedSuccessfully": "{{type}}删除成功",
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
"failedToDeleteItems": "删除项目失败",
"dragFilesToUpload": "拖拽文件到这里上传",
"emptyFolder": "此文件夹为空",
"itemCount": "{{count}} 个项目",
"selectedCount": "已选择 {{count}} 个",
"searchFiles": "搜索文件...",
"upload": "上传",
"selectHostToStart": "选择主机开始文件管理",
"failedToConnect": "连接SSH失败",
"failedToLoadDirectory": "加载目录失败",
"noSSHConnection": "无SSH连接可用",
"enterFolderName": "输入文件夹名称:",
"enterFileName": "输入文件名称:",
"cut": "剪切",
"properties": "属性",
"refresh": "刷新",
"downloadFiles": "下载 {{count}} 个文件",
"copyFiles": "复制 {{count}} 个项目",
"cutFiles": "剪切 {{count}} 个项目",
"deleteFiles": "删除 {{count}} 个项目",
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
"movedItems": "已移动 {{count}} 个项目",
"unknownSize": "未知大小",
"fileIsEmpty": "文件为空",
"modified": "修改时间",
"largeFileWarning": "大文件警告",
"largeFileWarningDesc": "此文件大小为 {{size}},以文本形式打开可能会导致性能问题。",
"fileNotFoundAndRemoved": "文件 \"{{name}}\" 未找到,已从最近访问/固定文件中移除",
"failedToLoadFile": "加载文件失败:{{error}}",
"serverErrorOccurred": "服务器错误,请稍后重试。",
"failedToDeleteItem": "删除项目失败",
"itemRenamedSuccessfully": "{{type}}重命名成功",
"failedToRenameItem": "重命名项目失败",
"upload": "上传",
"download": "下载",
"delete": "删除",
"permissions": "权限",
"size": "大小",
"modified": "修改时间",
"path": "路径",
"confirmDelete": "确定要删除 {{name}} 吗?",
"uploadSuccess": "文件上传成功",
"uploadFailed": "文件上传失败",
@@ -593,12 +839,11 @@
"serverError": "服务器错误",
"error": "错误",
"requestFailed": "请求失败,状态码",
"unknown": "未知",
"unknownFileError": "未知",
"cannotReadFile": "无法读取文件",
"noSshSessionId": "没有可用的 SSH 会话 ID",
"noFilePath": "没有可用的文件路径",
"noCurrentHost": "没有可用的当前主机",
"fileSavedSuccessfully": "文件保存成功",
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
"failedToSaveFile": "保存文件失败",
"deletedSuccessfully": "删除成功",
@@ -608,6 +853,18 @@
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
"actionCannotBeUndone": "此操作无法撤销。",
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
"openTerminalHere": "在此处打开终端",
"run": "运行",
"saveToSystem": "另存为...",
"selectLocationToSave": "选择位置保存",
"openTerminalInFolder": "在此文件夹打开终端",
"openTerminalInFileLocation": "在文件位置打开终端",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runningFile": "运行 - {{file}}",
"onlyRunExecutableFiles": "只能运行可执行文件",
"noHostSelected": "没有选择主机",
"recent": "最近的",
"pinned": "固定的",
"folderShortcuts": "文件夹快捷方式",
@@ -624,7 +881,95 @@
"sshStatusCheckTimeout": "SSH 状态检查超时",
"sshReconnectionTimeout": "SSH 重新连接超时",
"saveOperationTimeout": "保存操作超时",
"cannotSaveFile": "无法保存文件"
"cannotSaveFile": "无法保存文件",
"starred": "收藏",
"shortcuts": "快捷方式",
"directories": "目录",
"removedFromRecentFiles": "已从最近访问中移除\"{{name}}\"",
"removeFailed": "移除失败",
"unpinnedSuccessfully": "已取消固定\"{{name}}\"",
"unpinFailed": "取消固定失败",
"removedShortcut": "已移除快捷方式\"{{name}}\"",
"removeShortcutFailed": "移除快捷方式失败",
"clearedAllRecentFiles": "已清除所有最近访问记录",
"clearFailed": "清除失败",
"removeFromRecentFiles": "从最近访问中移除",
"clearAllRecentFiles": "清除所有最近访问",
"unpinFile": "取消固定",
"removeShortcut": "移除快捷方式",
"saveFilesToSystem": "另存 {{count}} 个文件为...",
"pinFile": "固定文件",
"addToShortcuts": "添加到快捷方式",
"downloadToDefaultLocation": "下载到默认位置",
"pasteFailed": "粘贴失败",
"noUndoableActions": "没有可撤销的操作",
"undoCopySuccess": "已撤销复制操作:删除了 {{count}} 个复制的文件",
"undoCopyFailedDelete": "撤销失败:无法删除任何复制的文件",
"undoCopyFailedNoInfo": "撤销失败:找不到复制的文件信息",
"undoMoveSuccess": "已撤销移动操作:移回了 {{count}} 个文件到原位置",
"undoMoveFailedMove": "撤销失败:无法移回任何文件",
"undoMoveFailedNoInfo": "撤销失败:找不到移动的文件信息",
"undoDeleteNotSupported": "删除操作无法撤销:文件已从服务器永久删除",
"undoTypeNotSupported": "不支持撤销此类操作",
"undoOperationFailed": "撤销操作失败",
"unknownError": "未知错误",
"enterPath": "输入路径...",
"editPath": "编辑路径",
"confirm": "确认",
"cancel": "取消",
"find": "查找...",
"replaceWith": "替换为...",
"replace": "替换",
"replaceAll": "全部替换",
"downloadInstead": "下载文件",
"keyboardShortcuts": "键盘快捷键",
"searchAndReplace": "搜索和替换",
"editing": "编辑",
"navigation": "导航",
"code": "代码",
"search": "搜索",
"findNext": "查找下一个",
"findPrevious": "查找上一个",
"save": "保存",
"selectAll": "全选",
"undo": "撤销",
"redo": "重做",
"goToLine": "跳转到行",
"moveLineUp": "向上移动行",
"moveLineDown": "向下移动行",
"toggleComment": "切换注释",
"indent": "增加缩进",
"outdent": "减少缩进",
"autoComplete": "自动补全",
"imageLoadError": "图片加载失败",
"rotate": "旋转",
"originalSize": "原始大小",
"startTyping": "开始输入...",
"moveFileFailed": "移动 {{name}} 失败",
"moveOperationFailed": "移动操作失败",
"canOnlyCompareFiles": "只能对比两个文件",
"comparingFiles": "正在对比文件:{{file1}} 与 {{file2}}",
"dragFailed": "拖拽失败",
"filePinnedSuccessfully": "文件\"{{name}}\"已固定",
"pinFileFailed": "固定文件失败",
"fileUnpinnedSuccessfully": "文件\"{{name}}\"已取消固定",
"unpinFileFailed": "取消固定失败",
"shortcutAddedSuccessfully": "文件夹快捷方式\"{{name}}\"已添加",
"addShortcutFailed": "添加快捷方式失败",
"operationCompletedSuccessfully": "已{{operation}} {{count}} 个项目",
"operationCompleted": "已{{operation}} {{count}} 个项目",
"downloadFileSuccess": "文件 {{name}} 下载成功",
"downloadFileFailed": "下载失败",
"moveTo": "移动到 {{name}}",
"diffCompareWith": "与 {{name}} 对比",
"dragOutsideToDownload": "拖拽到窗口外下载 ({{count}} 个文件)",
"newFolderDefault": "新文件夹",
"newFileDefault": "新文件.txt",
"successfullyMovedItems": "成功移动 {{count}} 个项目到 {{target}}",
"move": "移动",
"searchInFile": "在文件中搜索 (Ctrl+F)",
"showKeyboardShortcuts": "显示键盘快捷键",
"startWritingMarkdown": "开始编写您的 markdown 内容..."
},
"tunnels": {
"title": "SSH 隧道",
@@ -634,6 +979,7 @@
"disconnected": "已断开连接",
"connecting": "连接中...",
"disconnecting": "断开连接中...",
"unknownTunnelStatus": "未知",
"unknown": "未知",
"error": "错误",
"failed": "失败",
@@ -670,7 +1016,10 @@
"remote": "远程",
"dynamic": "动态",
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"endpointHostNotFound": "未找到端点主机"
"endpointHostNotFound": "未找到端点主机",
"discord": "Discord",
"githubIssue": "GitHub 问题",
"forHelp": "寻求帮助"
},
"serverStats": {
"title": "服务器统计",
@@ -775,7 +1124,7 @@
"enableTwoFactorButton": "启用双因素认证",
"addExtraSecurityLayer": "为您的账户添加额外的安全层",
"firstUser": "首位用户",
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志或创建",
"firstUserMessage": "作为您的第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是一个错误,请检查 Docker 日志或创建 GitHub 问题",
"external": "外部",
"loginWithExternal": "使用外部提供商登录",
"loginWithExternalDesc": "使用您配置的外部身份提供者登录",
@@ -800,8 +1149,9 @@
"forbidden": "访问被禁止",
"serverError": "服务器错误",
"networkError": "网络错误",
"databaseConnection": "无法连接到数据库。请稍后再试。",
"databaseConnection": "无法连接到数据库。",
"unknownError": "未知错误",
"loginFailed": "登录失败",
"failedPasswordReset": "无法启动密码重置",
"failedVerifyCode": "验证重置代码失败",
"failedCompleteReset": "无法完成密码重置",
@@ -821,7 +1171,8 @@
"usernameExists": "用户名已存在",
"emailExists": "邮箱已存在",
"loadFailed": "加载数据失败",
"saveError": "保存失败"
"saveError": "保存失败",
"sessionExpired": "会话已过期 - 请重新登录"
},
"messages": {
"saveSuccess": "保存成功",
@@ -838,7 +1189,15 @@
"reconnecting": "重新连接中...",
"processing": "处理中...",
"pleaseWait": "请稍候...",
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。"
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。",
"databaseConnected": "数据库连接成功",
"databaseConnectionFailed": "无法连接到数据库服务器",
"checkServerConnection": "请检查您的服务器连接并重试",
"resetCodeSent": "重置代码已发送到 Docker 日志",
"codeVerified": "代码验证成功",
"passwordResetSuccess": "密码重置成功",
"loginSuccess": "登录成功",
"registrationSuccess": "注册成功"
},
"profile": {
"title": "用户资料",
@@ -874,6 +1233,7 @@
"searchCredentials": "按名称、用户名或标签搜索凭据...",
"keyPassword": "密钥密码",
"pastePrivateKey": "在此粘贴您的私钥...",
"pastePublicKey": "在此粘贴您的公钥...",
"sshConfig": "端点 SSH 配置",
"homePath": "/home",
"clientId": "您的客户端 ID",
@@ -929,101 +1289,37 @@
"discord": "Discord",
"connectToSshForOperations": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
"deleteItem": "删除项目",
"createNewFile": "创建新文件",
"createNewFolder": "创建新文件夹",
"renameItem": "重命名项目",
"clickToSelectFile": "点击选择文件",
"noSshHosts": "没有 SSH 主机",
"sshHosts": "SSH 主机",
"importSshHosts": "从 JSON 导入 SSH 主机",
"clientId": "客户端 ID",
"clientSecret": "客户端密钥",
"error": "错误",
"warning": "警告",
"deleteAccount": "删除账户",
"closeDeleteAccount": "关闭删除账户",
"cannotDeleteAccount": "无法删除账户",
"confirmPassword": "确认密码",
"deleting": "删除中...",
"externalAuth": "外部认证 (OIDC)",
"configureExternalProvider": "配置外部身份提供者",
"waitingForRetry": "等待重试",
"retryingConnection": "重试连接中",
"resetSplitSizes": "重置分屏大小",
"sshManagerAlreadyOpen": "SSH 管理器已打开",
"disabledDuringSplitScreen": "分屏期间禁用",
"unknown": "未知",
"connected": "已连接",
"disconnected": "已断开连接",
"maxRetriesExhausted": "已达到最大重试次数",
"endpointHostNotFound": "未找到端点主机",
"administrator": "管理员",
"user": "用户",
"external": "外部",
"local": "本地",
"saving": "保存中...",
"saveConfiguration": "保存配置",
"loading": "加载中...",
"refresh": "刷新",
"adding": "添加中...",
"makeAdmin": "设为管理员",
"verifying": "验证中...",
"verifyAndEnable": "验证并启用",
"secretKey": "密钥",
"totpQrCode": "TOTP 二维码",
"passwordRequired": "使用密码认证时需要密码",
"sshKeyRequired": "使用密钥认证时需要 SSH 私钥",
"keyTypeRequired": "使用密钥认证时需要密钥类型",
"validSshConfigRequired": "必须从列表中选择有效的 SSH 配置",
"updateHost": "更新主机",
"addHost": "添加主机",
"editHost": "编辑主机",
"pinConnection": "固定连接",
"authentication": "认证",
"password": "密码",
"key": "密钥",
"sshPrivateKey": "SSH 私钥",
"keyPassword": "密钥密码",
"keyType": "密钥类型",
"enableTerminal": "启用终端",
"enableTunnel": "启用隧道",
"enableFileManager": "启用文件管理器",
"defaultPath": "默认路径",
"tunnelConnections": "隧道连接",
"maxRetries": "最大重试次数",
"upload": "上传",
"updateKey": "更新密钥",
"sshpassRequired": "密码认证需要 Sshpass",
"sshServerConfigRequired": "需要 SSH 服务器配置",
"productionFolder": "生产环境",
"databaseServer": "数据库服务器",
"unknownError": "未知错误",
"failedToInitiatePasswordReset": "启动密码重置失败",
"failedToVerifyResetCode": "验证重置代码失败",
"failedToCompletePasswordReset": "完成密码重置失败",
"invalidTotpCode": "无效的 TOTP 代码",
"developmentServer": "开发服务器",
"developmentFolder": "开发环境",
"webServerProduction": "Web 服务器 - 生产环境",
"failedToStartOidcLogin": "启动 OIDC 登录失败",
"failedToGetUserInfoAfterOidc": "OIDC 登录后获取用户信息失败",
"loginWithExternalProvider": "使用外部提供者登录",
"loginWithExternal": "使用外部提供者登录",
"sendResetCode": "发送重置代码",
"verifyCode": "验证代码",
"resetPassword": "重置密码",
"login": "登录",
"signUp": "注册",
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
"failedToMakeUserAdmin": "设为管理员失败",
"failedToStartTotpSetup": "启动 TOTP 设置失败",
"invalidVerificationCode": "无效的验证码",
"failedToDisableTotp": "禁用 TOTP 失败",
"failedToGenerateBackupCodes": "生成备用码失败"
"failedToStartTotpSetup": "启动 TOTP 设置失败"
},
"mobile": {
"selectHostToStart": "选择一个主机以开始您的终端会话",
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
"limitedSupportMessage": "网站移动端支持仍在开发中。使用移动应用以获得更好的体验。",
"mobileAppInProgress": "移动应用开发中",
"mobileAppInProgressDesc": "我们正在开发专门的移动应用,为移动设备提供更好的体验。",
"viewMobileAppDocs": "安装移动应用",
"mobileAppDocumentation": "移动应用文档"
}
}

65
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,65 @@
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
getServerConfig: () => Promise<any>;
saveServerConfig: (config: any) => Promise<any>;
testServerConnection: (serverUrl: string) => Promise<any>;
showSaveDialog: (options: any) => Promise<any>;
showOpenDialog: (options: any) => Promise<any>;
onUpdateAvailable: (callback: Function) => void;
onUpdateDownloaded: (callback: Function) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: any[]) => Promise<any>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
}
}

View File

@@ -2,7 +2,6 @@
// CENTRAL TYPE DEFINITIONS
// ============================================================================
// This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency.
import type { Client } from "ssh2";
@@ -24,6 +23,11 @@ export interface SSHHost {
key?: string;
keyPassword?: string;
keyType?: string;
autostartPassword?: string;
autostartKey?: string;
autostartKeyPassword?: string;
credentialId?: number;
userId?: string;
enableTerminal: boolean;
@@ -70,6 +74,7 @@ export interface Credential {
username: string;
password?: string;
key?: string;
publicKey?: string;
keyPassword?: string;
keyType?: string;
usageCount: number;
@@ -87,6 +92,7 @@ export interface CredentialData {
username: string;
password?: string;
key?: string;
publicKey?: string;
keyPassword?: string;
keyType?: string;
}
@@ -99,6 +105,14 @@ export interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
// Endpoint host credentials for tunnel authentication
endpointPassword?: string;
endpointKey?: string;
endpointKeyPassword?: string;
endpointAuthType?: string;
endpointKeyType?: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
@@ -180,8 +194,15 @@ export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory";
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
export interface ShortcutItem {
@@ -360,26 +381,6 @@ export interface FileManagerProps {
initialHost?: SSHHost | null;
}
export interface FileManagerLeftSidebarProps {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: Tab[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
}
export interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
}
export interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;

View File

@@ -21,7 +21,16 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Shield, Trash2, Users } from "lucide-react";
import {
Shield,
Trash2,
Users,
Database,
Key,
Lock,
Download,
Upload,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -82,10 +91,16 @@ export function AdminSettings({
null,
);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
const [securityInitialized, setSecurityInitialized] = React.useState(true);
const [exportLoading, setExportLoading] = React.useState(false);
const [importLoading, setImportLoading] = React.useState(false);
const [importFile, setImportFile] = React.useState<File | null>(null);
const [exportPassword, setExportPassword] = React.useState("");
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
const [importPassword, setImportPassword] = React.useState("");
React.useEffect(() => {
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
@@ -127,9 +142,6 @@ export function AdminSettings({
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
@@ -152,7 +164,6 @@ export function AdminSettings({
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
@@ -184,7 +195,6 @@ export function AdminSettings({
return;
}
const jwt = getCookie("jwt");
try {
await updateOIDCConfig(oidcConfig);
toast.success(t("admin.oidcConfigurationUpdated"));
@@ -206,7 +216,6 @@ export function AdminSettings({
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
@@ -223,7 +232,6 @@ export function AdminSettings({
const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
toast.success(t("admin.adminStatusRemoved", { username }));
@@ -238,7 +246,6 @@ export function AdminSettings({
confirmWithToast(
t("admin.deleteUser", { username }),
async () => {
const jwt = getCookie("jwt");
try {
await deleteUser(username);
toast.success(t("admin.userDeletedSuccessfully", { username }));
@@ -251,6 +258,168 @@ export function AdminSettings({
);
};
const handleExportDatabase = async () => {
if (!showPasswordInput) {
setShowPasswordInput(true);
return;
}
if (!exportPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setExportLoading(true);
try {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/export`
: isDev
? `http://localhost:30001/database/export`
: `${window.location.protocol}//${window.location.host}/database/export`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ password: exportPassword }),
});
if (response.ok) {
const blob = await response.blob();
const contentDisposition = response.headers.get("content-disposition");
const filename =
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
"termix-export.sqlite";
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("admin.databaseExportedSuccessfully"));
setExportPassword("");
setShowPasswordInput(false);
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseExportFailed"));
}
}
} catch (err) {
toast.error(t("admin.databaseExportFailed"));
} finally {
setExportLoading(false);
}
};
const handleImportDatabase = async () => {
if (!importFile) {
toast.error(t("admin.pleaseSelectImportFile"));
return;
}
if (!importPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setImportLoading(true);
try {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/import`
: isDev
? `http://localhost:30001/database/import`
: `${window.location.protocol}//${window.location.host}/database/import`;
const formData = new FormData();
formData.append("file", importFile);
formData.append("password", importPassword);
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include",
body: formData,
});
if (response.ok) {
const result = await response.json();
if (result.success) {
const summary = result.summary;
const imported =
summary.sshHostsImported +
summary.sshCredentialsImported +
summary.fileManagerItemsImported +
summary.dismissedAlertsImported +
(summary.settingsImported || 0);
const skipped = summary.skippedItems;
const details = [];
if (summary.sshHostsImported > 0)
details.push(`${summary.sshHostsImported} SSH hosts`);
if (summary.sshCredentialsImported > 0)
details.push(`${summary.sshCredentialsImported} credentials`);
if (summary.fileManagerItemsImported > 0)
details.push(
`${summary.fileManagerItemsImported} file manager items`,
);
if (summary.dismissedAlertsImported > 0)
details.push(`${summary.dismissedAlertsImported} alerts`);
if (summary.settingsImported > 0)
details.push(`${summary.settingsImported} settings`);
toast.success(
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
);
setImportFile(null);
setImportPassword("");
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
toast.error(
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
);
}
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseImportFailed"));
}
}
} catch (err) {
toast.error(t("admin.databaseImportFailed"));
} finally {
setImportLoading(false);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
@@ -295,6 +464,10 @@ export function AdminSettings({
<Shield className="h-4 w-4" />
{t("admin.adminManagement")}
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Database className="h-4 w-4" />
{t("admin.databaseSecurity")}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
@@ -680,6 +853,151 @@ export function AdminSettings({
</div>
</div>
</TabsContent>
<TabsContent value="security" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Database className="h-5 w-5" />
<h3 className="text-lg font-semibold">
{t("admin.databaseSecurity")}
</h3>
</div>
<div className="p-4 border rounded bg-card">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-green-500" />
<div>
<div className="text-sm font-medium">
{t("admin.encryptionStatus")}
</div>
<div className="text-xs text-green-500">
{t("admin.encryptionEnabled")}
</div>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="p-4 border rounded bg-card">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.export")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.exportDescription")}
</p>
{showPasswordInput && (
<div className="space-y-2">
<Label htmlFor="export-password">Password</Label>
<PasswordInput
id="export-password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleExportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleExportDatabase}
disabled={exportLoading}
className="w-full"
>
{exportLoading
? t("admin.exporting")
: showPasswordInput
? t("admin.confirmExport")
: t("admin.export")}
</Button>
{showPasswordInput && (
<Button
variant="outline"
onClick={() => {
setShowPasswordInput(false);
setExportPassword("");
}}
className="w-full"
>
Cancel
</Button>
)}
</div>
</div>
<div className="p-4 border rounded bg-card">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" />
<h4 className="font-medium">{t("admin.import")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.importDescription")}
</p>
<div className="relative inline-block w-full mb-2">
<input
id="import-file-upload"
type="file"
accept=".sqlite,.db"
onChange={(e) =>
setImportFile(e.target.files?.[0] || null)
}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span
className="truncate"
title={
importFile?.name ||
t("admin.pleaseSelectImportFile")
}
>
{importFile
? importFile.name
: t("admin.pleaseSelectImportFile")}
</span>
</Button>
</div>
{importFile && (
<div className="space-y-2">
<Label htmlFor="import-password">Password</Label>
<PasswordInput
id="import-password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleImportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleImportDatabase}
disabled={
importLoading || !importFile || !importPassword.trim()
}
className="w-full"
>
{importLoading
? t("admin.importing")
: t("admin.import")}
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -22,8 +22,15 @@ import {
updateCredential,
getCredentials,
getCredentialDetails,
detectKeyType,
detectPublicKeyType,
generatePublicKeyFromPrivate,
generateKeyPair,
} from "@/ui/main-axios";
import { useTranslation } from "react-i18next";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import type {
Credential,
CredentialEditorProps,
@@ -42,9 +49,16 @@ export function CredentialEditor({
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload",
);
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const fetchData = async () => {
@@ -101,6 +115,7 @@ export function CredentialEditor({
username: z.string().min(1),
password: z.string().optional(),
key: z.any().optional().nullable(),
publicKey: z.string().optional(),
keyPassword: z.string().optional(),
keyType: z
.enum([
@@ -149,6 +164,7 @@ export function CredentialEditor({
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
},
@@ -169,6 +185,7 @@ export function CredentialEditor({
username: fullCredentialDetails.username || "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto" as const,
};
@@ -176,7 +193,8 @@ export function CredentialEditor({
if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === "key") {
formData.key = "existing_key";
formData.key = fullCredentialDetails.key || "";
formData.publicKey = fullCredentialDetails.publicKey || "";
formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType =
(fullCredentialDetails.keyType as any) || ("auto" as const);
@@ -196,6 +214,7 @@ export function CredentialEditor({
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
});
@@ -203,6 +222,100 @@ export function CredentialEditor({
}
}, [editingCredential?.id, fullCredentialDetails, form]);
useEffect(() => {
return () => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
};
}, []);
const handleKeyTypeDetection = async (
keyValue: string,
keyPassword?: string,
) => {
if (!keyValue || keyValue.trim() === "") {
setDetectedKeyType(null);
return;
}
setKeyDetectionLoading(true);
try {
const result = await detectKeyType(keyValue, keyPassword);
if (result.success) {
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType("invalid");
}
} catch (error) {
setDetectedKeyType("error");
console.error("Key type detection error:", error);
} finally {
setKeyDetectionLoading(false);
}
};
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
keyDetectionTimeoutRef.current = setTimeout(() => {
handleKeyTypeDetection(keyValue, keyPassword);
}, 1000);
};
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null);
return;
}
setPublicKeyDetectionLoading(true);
try {
const result = await detectPublicKeyType(publicKeyValue);
if (result.success) {
setDetectedPublicKeyType(result.keyType);
} else {
setDetectedPublicKeyType("invalid");
console.warn("Public key detection failed:", result.error);
}
} catch (error) {
setDetectedPublicKeyType("error");
console.error("Public key type detection error:", error);
} finally {
setPublicKeyDetectionLoading(false);
}
};
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
publicKeyDetectionTimeoutRef.current = setTimeout(() => {
handlePublicKeyTypeDetection(publicKeyValue);
}, 1000);
};
const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = {
"ssh-rsa": "RSA (SSH)",
"ssh-ed25519": "Ed25519 (SSH)",
"ecdsa-sha2-nistp256": "ECDSA P-256 (SSH)",
"ecdsa-sha2-nistp384": "ECDSA P-384 (SSH)",
"ecdsa-sha2-nistp521": "ECDSA P-521 (SSH)",
"ssh-dss": "DSA (SSH)",
"rsa-sha2-256": "RSA-SHA2-256",
"rsa-sha2-512": "RSA-SHA2-512",
invalid: t("credentials.invalidKey"),
error: t("credentials.detectionError"),
unknown: t("credentials.unknown"),
};
return keyTypeMap[keyType] || keyType;
};
const onSubmit = async (data: FormData) => {
try {
if (!data.name || data.name.trim() === "") {
@@ -221,20 +334,15 @@ export function CredentialEditor({
submitData.password = null;
submitData.key = null;
submitData.publicKey = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
} else if (data.key === "existing_key") {
delete submitData.key;
} else {
submitData.key = data.key;
}
submitData.key = data.key;
submitData.publicKey = data.publicKey;
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
@@ -259,7 +367,12 @@ export function CredentialEditor({
form.reset();
} catch (error) {
toast.error(t("credentials.failedToSaveCredential"));
console.error("Credential save error:", error);
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error(t("credentials.failedToSaveCredential"));
}
}
};
@@ -305,39 +418,6 @@ export function CredentialEditor({
};
}, [folderDropdownOpen]);
const keyTypeOptions = [
{ value: "auto", label: t("hosts.autoDetect") },
{ value: "ssh-rsa", label: t("hosts.rsa") },
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
{ value: "ssh-dss", label: t("hosts.dsa") },
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (
keyTypeDropdownOpen &&
keyTypeDropdownRef.current &&
!keyTypeDropdownRef.current.contains(event.target as Node) &&
keyTypeButtonRef.current &&
!keyTypeButtonRef.current.contains(event.target as Node)
) {
setKeyTypeDropdownOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
return (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
@@ -359,10 +439,10 @@ export function CredentialEditor({
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
<FormLabel className="mb-2 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<div className="grid grid-cols-12 gap-3">
<FormField
control={form.control}
name="name"
@@ -395,10 +475,10 @@ export function CredentialEditor({
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
<FormLabel className="mb-2 mt-4 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-4">
<div className="grid grid-cols-26 gap-3">
<FormField
control={form.control}
name="description"
@@ -542,7 +622,7 @@ export function CredentialEditor({
</div>
</TabsContent>
<TabsContent value="authentication">
<FormLabel className="mb-3 font-bold">
<FormLabel className="mb-2 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs
@@ -589,246 +669,454 @@ export function CredentialEditor({
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
onValueChange={(value) => {
setKeyInputMethod(value as "upload" | "paste");
if (value === "upload") {
form.setValue("key", null);
} else {
form.setValue("key", "");
}
}}
className="w-full"
>
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">
{t("hosts.uploadFile")}
</TabsTrigger>
<TabsTrigger value="paste">
{t("hosts.pasteKey")}
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<div className="mt-2">
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
<FormLabel className="mb-2 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
<div className="mb-2">
<div className="text-sm text-muted-foreground">
{t("credentials.generateKeyPairDescription")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateEd25519")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateECDSA")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-rsa",
2048,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateRSA")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl>
<div className="relative inline-block">
<div className="mb-1">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error(
"Failed to read uploaded file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="justify-start text-left"
className="w-full justify-start text-left"
>
<span
className="truncate"
title={
field.value?.name ||
t("credentials.upload")
}
>
{field.value === "existing_key"
? t("hosts.existingKey")
: field.value
? editingCredential
? t("credentials.updateKey")
: field.value.name
: t("credentials.upload")}
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
<CodeMirror
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) =>
field.onChange(e.target.value)
}
onChange={(value) => {
field.onChange(value);
debouncedKeyDetection(
value,
form.watch("keyPassword"),
);
}}
placeholder={t(
"placeholders.pastePrivateKey",
)}
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPublicKey")}
</FormLabel>
<div className="mb-1 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(
fileContent,
);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return;
}
try {
const keyPassword =
form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
field.onChange(result.publicKey);
debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else {
toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={(value) => {
field.onChange(value);
debouncedPublicKeyDetection(value);
}}
placeholder={t("placeholders.pastePublicKey")}
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</TabsContent>
</Tabs>
</TabsContent>

View File

@@ -218,7 +218,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</SheetHeader>
<div className="space-y-10">
{/* Tab Navigation */}
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
@@ -249,7 +248,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</Button>
</div>
{/* Tab Content */}
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">

View File

@@ -9,6 +9,21 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -29,12 +44,17 @@ import {
Pencil,
X,
Check,
Upload,
Server,
User,
} from "lucide-react";
import {
getCredentials,
deleteCredential,
updateCredential,
renameCredentialFolder,
deployCredentialToHost,
getSSHHosts,
} from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -65,12 +85,68 @@ export function CredentialsManager({
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [showDeployDialog, setShowDeployDialog] = useState(false);
const [deployingCredential, setDeployingCredential] =
useState<Credential | null>(null);
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false);
const [hostSearchQuery, setHostSearchQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const dragCounter = useRef(0);
useEffect(() => {
fetchCredentials();
fetchHosts();
}, []);
useEffect(() => {
if (showDeployDialog) {
setDropdownOpen(false);
setHostSearchQuery("");
setSelectedHostId("");
setTimeout(() => {
if (
document.activeElement &&
(document.activeElement as HTMLElement).blur
) {
(document.activeElement as HTMLElement).blur();
}
}, 50);
}
}, [showDeployDialog]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
const fetchHosts = async () => {
try {
const hosts = await getSSHHosts();
setAvailableHosts(hosts);
} catch (err) {
console.error("Failed to fetch hosts:", err);
}
};
const fetchCredentials = async () => {
try {
setLoading(true);
@@ -90,6 +166,51 @@ export function CredentialsManager({
}
};
const handleDeploy = (credential: Credential) => {
if (credential.authType !== "key") {
toast.error("Only SSH key-based credentials can be deployed");
return;
}
if (!credential.publicKey) {
toast.error("Public key is required for deployment");
return;
}
setDeployingCredential(credential);
setSelectedHostId("");
setHostSearchQuery("");
setDropdownOpen(false);
setShowDeployDialog(true);
};
const performDeploy = async () => {
if (!deployingCredential || !selectedHostId) {
toast.error("Please select a target host");
return;
}
setDeployLoading(true);
try {
const result = await deployCredentialToHost(
deployingCredential.id,
parseInt(selectedHostId),
);
if (result.success) {
toast.success(result.message || "SSH key deployed successfully");
setShowDeployDialog(false);
setDeployingCredential(null);
setSelectedHostId("");
} else {
toast.error(result.error || "Deployment failed");
}
} catch (error) {
console.error("Deployment error:", error);
toast.error("Failed to deploy SSH key");
} finally {
setDeployLoading(false);
}
};
const handleDelete = async (credentialId: number, credentialName: string) => {
confirmWithToast(
t("credentials.confirmDeleteCredential", { name: credentialName }),
@@ -577,6 +698,26 @@ export function CredentialsManager({
<p>Edit credential</p>
</TooltipContent>
</Tooltip>
{credential.authType === "key" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeploy(credential);
}}
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-500/10"
>
<Upload className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Deploy SSH key to host</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -687,6 +828,210 @@ export function CredentialsManager({
}}
/>
)}
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
<div className="px-4 py-4">
<div className="space-y-3 pb-4">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<Upload className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<div className="text-lg font-semibold">
{t("credentials.deploySSHKey")}
</div>
<div className="text-sm text-muted-foreground">
{t("credentials.deploySSHKeyDescription")}
</div>
</div>
</div>
</div>
<div className="space-y-4">
{deployingCredential && (
<div className="border rounded-lg p-3 bg-muted/20">
<h4 className="text-sm font-semibold mb-2 flex items-center">
<Key className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.sourceCredential")}
</h4>
<div className="space-y-2">
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<User className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("common.name")}
</div>
<div className="text-sm font-medium">
{deployingCredential.name ||
deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<User className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("common.username")}
</div>
<div className="text-sm font-medium">
{deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<Key className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("credentials.keyType")}
</div>
<div className="text-sm font-medium">
{deployingCredential.keyType || "SSH Key"}
</div>
</div>
</div>
</div>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-semibold flex items-center">
<Server className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.targetHost")}
</label>
<div className="relative" ref={dropdownRef}>
<Input
placeholder={t("credentials.chooseHostToDeploy")}
value={hostSearchQuery}
onChange={(e) => {
setHostSearchQuery(e.target.value);
}}
onClick={() => {
setDropdownOpen(true);
}}
className="w-full"
autoFocus={false}
tabIndex={0}
/>
{dropdownOpen && (
<div className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
{availableHosts.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsAvailable")}
</div>
) : availableHosts.filter(
(host) =>
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
).length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsMatchSearch")}
</div>
) : (
availableHosts
.filter(
(host) =>
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
)
.map((host) => (
<div
key={host.id}
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer"
onClick={() => {
setSelectedHostId(host.id.toString());
setHostSearchQuery(host.name || host.ip);
setDropdownOpen(false);
}}
>
<div className="p-1.5 rounded bg-muted">
<Server className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium text-foreground">
{host.name || host.ip}
</div>
<div className="text-xs text-muted-foreground">
{host.username}@{host.ip}:{host.port}
</div>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">
{t("credentials.deploymentProcess")}
</p>
<p className="text-blue-700 dark:text-blue-300">
{t("credentials.deploymentProcessDescription")}
</p>
</div>
</div>
</div>
<div className="mt-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowDeployDialog(false)}
disabled={deployLoading}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button
onClick={performDeploy}
disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
>
{deployLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
{t("credentials.deploying")}
</div>
) : (
<div className="flex items-center">
<Upload className="h-4 w-4 mr-2" />
{t("credentials.deploySSHKey")}
</div>
)}
</Button>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps {
tabs: { id: string | number; title: string }[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FIleManagerTopNavbar(
props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return (
<FileManagerTabList
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,486 @@
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Share,
ExternalLink,
Terminal,
Play,
Star,
Bookmark,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
executable?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
onDragToDesktop?: () => void;
onOpenTerminal?: (path: string) => void;
onRunExecutable?: (file: FileItem) => void;
onPinFile?: (file: FileItem) => void;
onUnpinFile?: (file: FileItem) => void;
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false,
onDragToDesktop,
onOpenTerminal,
onRunExecutable,
onPinFile,
onUnpinFile,
onAddShortcut,
isPinned,
currentPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
useEffect(() => {
if (!isVisible) return;
const adjustPosition = () => {
const menuWidth = 200;
const menuHeight = 300;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth) {
adjustedX = viewportWidth - menuWidth - 10;
}
if (y + menuHeight > viewportHeight) {
adjustedY = viewportHeight - menuHeight - 10;
}
setMenuPosition({ x: adjustedX, y: adjustedY });
};
adjustPosition();
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
}
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => {
onClose();
};
const handleScroll = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
if (cleanupFn) {
cleanupFn();
}
};
}, [isVisible, x, y, onClose]);
if (!isVisible) return null;
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some((f) => f.type === "file");
const hasDirectories = files.some((f) => f.type === "directory");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
const menuItems: MenuItem[] = [];
if (isFileContext) {
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+Shift+T",
});
}
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="w-4 h-4" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter",
});
}
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== "file",
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: <Star className="w-4 h-4 fill-yellow-400" />,
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="w-4 h-4" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]),
});
}
}
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="w-4 h-4" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path),
});
}
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F6",
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C",
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X",
});
}
if ((isSingleFile && onRename) || onCopy || onCut) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true,
});
}
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+Shift+T",
});
}
if (onUpload) {
menuItems.push({
icon: <Upload className="w-4 h-4" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U",
});
}
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N",
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N",
});
}
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "Ctrl+Y",
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V",
});
}
}
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
return (
<>
<div className="fixed inset-0 z-[99990]" />
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="border-t border-dark-border"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10",
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1">{item.label}</span>
</div>
{item.shortcut && (
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{item.shortcut}
</span>
)}
</button>
);
})}
</div>
</>
);
}

View File

@@ -1,338 +0,0 @@
import React, { useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function FileManagerFileEditor({
content,
fileName,
onContentChange,
}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== "string") {
return "text";
}
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "text";
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case "ng":
return "angular";
case "apl":
return "apl";
case "asc":
return "asciiArmor";
case "ast":
return "asterisk";
case "bf":
return "brainfuck";
case "c":
return "c";
case "ceylon":
return "ceylon";
case "clj":
return "clojure";
case "cmake":
return "cmake";
case "cob":
case "cbl":
return "cobol";
case "coffee":
return "coffeescript";
case "lisp":
return "commonLisp";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "cr":
return "crystal";
case "cs":
return "csharp";
case "css":
return "css";
case "cypher":
return "cypher";
case "d":
return "d";
case "dart":
return "dart";
case "diff":
case "patch":
return "diff";
case "dockerfile":
return "dockerfile";
case "dtd":
return "dtd";
case "dylan":
return "dylan";
case "ebnf":
return "ebnf";
case "ecl":
return "ecl";
case "eiffel":
return "eiffel";
case "elm":
return "elm";
case "erl":
return "erlang";
case "factor":
return "factor";
case "fcl":
return "fcl";
case "fs":
return "forth";
case "f90":
case "for":
return "fortran";
case "s":
return "gas";
case "feature":
return "gherkin";
case "go":
return "go";
case "groovy":
return "groovy";
case "hs":
return "haskell";
case "hx":
return "haxe";
case "html":
case "htm":
return "html";
case "http":
return "http";
case "idl":
return "idl";
case "java":
return "java";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "jinja2":
case "j2":
return "jinja2";
case "json":
return "json";
case "jsx":
return "jsx";
case "jl":
return "julia";
case "kt":
case "kts":
return "kotlin";
case "less":
return "less";
case "lezer":
return "lezer";
case "liquid":
return "liquid";
case "litcoffee":
return "livescript";
case "lua":
return "lua";
case "md":
return "markdown";
case "nb":
case "mat":
return "mathematica";
case "mbox":
return "mbox";
case "mmd":
return "mermaid";
case "mrc":
return "mirc";
case "moo":
return "modelica";
case "mscgen":
return "mscgen";
case "m":
return "mumps";
case "sql":
return "mysql";
case "nc":
return "nesC";
case "nginx":
return "nginx";
case "nix":
return "nix";
case "nsi":
return "nsis";
case "nt":
return "ntriples";
case "mm":
return "objectiveCpp";
case "octave":
return "octave";
case "oz":
return "oz";
case "pas":
return "pascal";
case "pl":
case "pm":
return "perl";
case "pgsql":
return "pgsql";
case "php":
return "php";
case "pig":
return "pig";
case "ps1":
return "powershell";
case "properties":
return "properties";
case "proto":
return "protobuf";
case "pp":
return "puppet";
case "py":
return "python";
case "q":
return "q";
case "r":
return "r";
case "rb":
return "ruby";
case "rs":
return "rust";
case "sas":
return "sas";
case "sass":
case "scss":
return "sass";
case "scala":
return "scala";
case "scm":
return "scheme";
case "shader":
return "shader";
case "sh":
case "bash":
return "shell";
case "siv":
return "sieve";
case "st":
return "smalltalk";
case "sol":
return "solidity";
case "solr":
return "solr";
case "rq":
return "sparql";
case "xlsx":
case "ods":
case "csv":
return "spreadsheet";
case "nut":
return "squirrel";
case "tex":
return "stex";
case "styl":
return "stylus";
case "svelte":
return "svelte";
case "swift":
return "swift";
case "tcl":
return "tcl";
case "textile":
return "textile";
case "tiddlywiki":
return "tiddlyWiki";
case "tiki":
return "tiki";
case "toml":
return "toml";
case "troff":
return "troff";
case "tsx":
return "tsx";
case "ttcn":
return "ttcn";
case "ttl":
case "turtle":
return "turtle";
case "ts":
return "typescript";
case "vb":
return "vb";
case "vbs":
return "vbscript";
case "vm":
return "velocity";
case "v":
return "verilog";
case "vhd":
case "vhdl":
return "vhdl";
case "vue":
return "vue";
case "wat":
return "wast";
case "webidl":
return "webIDL";
case "xq":
case "xquery":
return "xQuery";
case "xml":
return "xml";
case "yacas":
return "yacas";
case "yaml":
case "yml":
return "yaml";
case "z80":
return "z80";
default:
return "text";
}
}
useEffect(() => {
document.body.style.overflowX = "hidden";
return () => {
document.body.style.overflowX = "";
};
}, []);
return (
<div className="w-full h-full relative overflow-hidden flex flex-col">
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
[],
hyperLink,
oneDark,
EditorView.theme({
"&": {
backgroundColor: "var(--color-dark-bg-darkest) !important",
},
".cm-gutters": {
backgroundColor: "var(--color-dark-bg) !important",
},
}),
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{ lineNumbers: true }}
className="min-h-full min-w-full flex-1"
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,234 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { FileItem, ShortcutItem } from "../../../types/index";
interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut,
}: FileManagerHomeViewProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
const [newShortcut, setNewShortcut] = useState("");
const renderFileCard = (
file: FileItem,
onRemove: () => void,
onPin?: () => void,
isPinned = false,
) => (
<div
key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div
key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
className="w-full"
>
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="recent"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.recent")}
</TabsTrigger>
<TabsTrigger
value="pinned"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.pinned")}
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.folderShortcuts")}
</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noRecentFiles")}
</span>
</div>
) : (
recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
file.isPinned,
),
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noPinnedFiles")}
</span>
</div>
) : (
pinned.map((file) =>
renderFileCard(file, undefined, () => onUnpinFile(file), true),
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input
placeholder={t("fileManager.enterFolderPath")}
value={newShortcut}
onChange={(e) => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Enter" && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
{t("common.add")}
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noShortcuts")}
</span>
</div>
) : (
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,630 +0,0 @@
import React, {
useEffect,
useState,
useRef,
forwardRef,
useImperativeHandle,
} from "react";
import {
Folder,
File,
ArrowUp,
Pin,
MoreVertical,
Trash2,
Edit3,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { cn } from "@/lib/utils.ts";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
listSSHFiles,
renameSSHItem,
deleteSSHItem,
getFileManagerPinned,
addFileManagerPinned,
removeFileManagerPinned,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios.ts";
import type { SSHHost } from "../../../types/index.js";
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{
onOpenFile,
tabs,
host,
onOperationComplete,
onPathChange,
onDeleteItem,
}: {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
},
ref,
) {
const { t } = useTranslation();
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileSearch, setFileSearch] = useState("");
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<
Record<
string,
{
sessionId: string;
timestamp: number;
}
>
>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: any;
}>({
visible: false,
x: 0,
y: 0,
item: null,
});
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
const nextPath = host?.defaultPath || "/";
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
toast.error(t("common.noAuthCredentials"));
return null;
}
const connectionConfig = {
hostId: server.id,
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
authType: server.authType,
credentialId: server.credentialId,
userId: server.userId,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache((prev) => ({
...prev,
[sessionId]: { sessionId, timestamp: Date.now() },
}));
return sessionId;
} catch (err: any) {
toast.error(
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
);
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
try {
let pinnedFiles: any[] = [];
try {
if (host) {
pinnedFiles = await getFileManagerPinned(host.id);
}
} catch (err) {}
if (host && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath =
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
const isPinned = pinnedFiles.some(
(pinned) => pinned.path === filePath,
);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId,
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
toast.error(
err?.response?.data?.error ||
err?.message ||
t("fileManager.failedToListFiles"),
);
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, host, sshSessionId]);
useImperativeHandle(ref, () => ({
openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFiles([]);
setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId);
}
},
fetchFiles: () => {
if (host && sshSessionId) {
fetchFiles();
}
},
getCurrentPath: () => currentPath,
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const filteredFiles = files.filter((file) => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
setContextMenu({
visible: true,
x,
y,
item,
});
};
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, item: null });
};
const handleRename = async (item: any, newName: string) => {
if (!sshSessionId || !newName.trim() || newName === item.name) {
setRenamingItem(null);
return;
}
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
);
setRenamingItem(null);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
}
};
const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name });
closeContextMenu();
};
const startDelete = (item: any) => {
onDeleteItem?.(item);
closeContextMenu();
};
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const handlePathChange = (newPath: string) => {
setCurrentPath(newPath);
onPathChange?.(newPath);
};
return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0">
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full max-w-[260px]">
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
<Button
size="icon"
variant="outline"
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== "/" && path !== "") {
if (path.endsWith("/")) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf("/");
if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash));
} else {
handlePathChange("/");
}
} else {
handlePathChange("/");
}
}}
>
<ArrowUp className="w-4 h-4" />
</Button>
<Input
ref={pathInputRef}
value={currentPath}
onChange={(e) => handlePathChange(e.target.value)}
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
/>
</div>
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
<Input
placeholder={t("fileManager.searchFilesAndFolders")}
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off"
value={fileSearch}
onChange={(e) => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
<div className="p-2 pb-0">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">
{t("fileManager.noFilesOrFoldersFound")}
</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some(
(t: any) => t.id === item.path,
);
const isRenaming =
renamingItem?.item?.path === item.path;
const isDeleting = false;
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
isOpen &&
"opacity-60 cursor-not-allowed pointer-events-none",
)}
onContextMenu={(e) =>
!isOpen && handleContextMenu(e, item)
}
>
{isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<Input
value={renamingItem.newName}
onChange={(e) =>
setRenamingItem((prev) =>
prev
? {
...prev,
newName: e.target.value,
}
: null,
)
}
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRename(
item,
renamingItem.newName,
);
} else if (e.key === "Escape") {
setRenamingItem(null);
}
}}
onBlur={() =>
handleRename(item, renamingItem.newName)
}
/>
</div>
) : (
<>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() =>
!isOpen &&
(item.type === "directory"
? handlePathChange(item.path)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<span className="text-sm text-white truncate flex-1 min-w-0">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
{item.type === "file" && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: false,
}
: f,
),
);
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: true,
}
: f,
),
);
}
} catch (err) {}
}}
>
<Pin
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
});
export { FileManagerLeftSidebar };

View File

@@ -1,128 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Folder, File, Trash2, Pin } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
isStarred?: boolean;
}
interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function FileManagerLeftSidebarFileViewer({
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
}: FileManagerLeftSidebarVileViewerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-semibold">
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card
key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() =>
item.type === "directory"
? onOpenFolder(item)
: onOpenFile(item)
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400" />
) : (
<File className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm text-white truncate">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onStarFile(item)}
>
<Pin
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</Card>
))}
{files.length === 0 && (
<div className="text-xs text-muted-foreground">
No files or folders found.
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,805 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Upload,
FilePlus,
FolderPlus,
Trash2,
Edit3,
X,
AlertCircle,
FileText,
Folder,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import type { FileManagerOperationsProps } from "../../../types/index.js";
export function FileManagerOperations({
currentPath,
sshSessionId,
onOperationComplete,
onError,
onSuccess,
}: FileManagerOperationsProps) {
const { t } = useTranslation();
const [showUpload, setShowUpload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [newFileName, setNewFileName] = useState("");
const [newFolderName, setNewFolderName] = useState("");
const [deletePath, setDeletePath] = useState("");
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState("");
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkContainerWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setShowTextLabels(width > 240);
}
};
checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.uploadingFile", { name: uploadFile.name }),
);
try {
const content = await uploadFile.text();
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
const response = await uploadSSHFile(
sshSessionId,
currentPath,
uploadFile.name,
content,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
);
}
setShowUpload(false);
setUploadFile(null);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFile", { name: newFileName.trim() }),
);
try {
const { createSSHFile } = await import("@/ui/main-axios.ts");
const response = await createSSHFile(
sshSessionId,
currentPath,
newFileName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileCreatedSuccessfully", {
name: newFileName.trim(),
}),
);
}
setShowCreateFile(false);
setNewFileName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
);
try {
const { createSSHFolder } = await import("@/ui/main-axios.ts");
const response = await createSSHFolder(
sshSessionId,
currentPath,
newFolderName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.folderCreatedSuccessfully", {
name: newFolderName.trim(),
}),
);
}
setShowCreateFolder(false);
setNewFolderName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deletePath || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.deletingItem", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
name: deletePath.split("/").pop(),
}),
);
try {
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
const response = await deleteSSHItem(
sshSessionId,
deletePath,
deleteIsDirectory,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemDeletedSuccessfully", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowDelete(false);
setDeletePath("");
setDeleteIsDirectory(false);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
);
} finally {
setIsLoading(false);
}
};
const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.renamingItem", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
oldName: renamePath.split("/").pop(),
newName: newName.trim(),
}),
);
try {
const { renameSSHItem } = await import("@/ui/main-axios.ts");
const response = await renameSSHItem(
sshSessionId,
renamePath,
newName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemRenamedSuccessfully", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowRename(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
} finally {
setIsLoading(false);
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setUploadFile(file);
}
};
const resetStates = () => {
setShowUpload(false);
setShowCreateFile(false);
setShowCreateFolder(false);
setShowDelete(false);
setShowRename(false);
setUploadFile(null);
setNewFileName("");
setNewFolderName("");
setDeletePath("");
setDeleteIsDirectory(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
};
if (!sshSessionId) {
return (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{t("fileManager.connectToSsh")}
</p>
</div>
);
}
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.uploadFile")}
>
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.uploadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFile")}
>
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFolder")}
>
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFolder")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.rename")}
>
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.rename")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2"
title={t("fileManager.deleteItem")}
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.deleteItem")}</span>
)}
</Button>
</div>
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
<div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">
{t("fileManager.currentPath")}:
</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">
{currentPath}
</span>
</div>
</div>
</div>
<Separator className="p-0.25 bg-dark-border" />
{showUpload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.uploadFileTitle")}
</span>
</h3>
<p className="text-xs text-muted-foreground break-words">
{t("fileManager.maxFileSize")}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
{uploadFile ? (
<div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
<p className="text-white font-medium text-sm break-words px-2">
{uploadFile.name}
</p>
<p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="w-full text-sm h-8"
>
{t("fileManager.removeFile")}
</Button>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-white text-sm break-words px-2">
{t("fileManager.clickToSelectFile")}
</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
className="w-full text-sm h-8"
>
{t("fileManager.chooseFile")}
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.uploading")
: t("fileManager.uploadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.fileName")}
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={t("placeholders.fileName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFolder && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFolder")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.folderName")}
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("placeholders.folderName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFolder")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showDelete && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
<span className="break-words">
{t("fileManager.deleteItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium break-words">
{t("fileManager.warningCannotUndo")}
</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.itemPath")}
</label>
<Input
value={deletePath}
onChange={(e) => setDeletePath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="deleteIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectory")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.deleting")
: t("fileManager.deleteItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showRename && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.renameItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.currentPathLabel")}
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder={t("placeholders.currentPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.newName")}
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("placeholders.newName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleRename()}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="renameIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectoryRename")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.renaming")
: t("fileManager.renameItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,545 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
ChevronRight,
ChevronDown,
Folder,
File,
Star,
Clock,
Bookmark,
FolderOpen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
export interface SidebarItem {
id: string;
name: string;
path: string;
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
}
interface FileManagerSidebarProps {
currentHost: SSHHost;
currentPath: string;
onPathChange: (path: string) => void;
onLoadDirectory?: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
}
export function FileManagerSidebar({
currentHost,
currentPath,
onPathChange,
onLoadDirectory,
onFileOpen,
sshSessionId,
refreshTrigger,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["root"]),
);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
item: SidebarItem | null;
}>({
x: 0,
y: 0,
isVisible: false,
item: null,
});
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadDirectoryTree();
}
}, [sshSessionId]);
const loadQuickAccessData = async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = recentData.slice(0, 5).map((item: any) => ({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = pinnedData.map((item: any) => ({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: "pinned" as const,
}));
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = shortcutData.map((item: any) => ({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: "shortcut" as const,
}));
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
};
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
isVisible: true,
item,
});
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) {
closeContextMenu();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeContextMenu();
}
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
const loadDirectoryTree = async () => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = response.files || [];
const rootFolders = rootFiles.filter(
(item: any) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: any) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error) {
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
};
const handleItemClick = (item: SidebarItem) => {
if (item.type === "folder") {
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
onPathChange(item.path);
}
};
const toggleFolder = async (folderId: string, folderPath?: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = subResponse.files || [];
const subFolders = subFiles.filter(
(item: any) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: any) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
} else if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
};
return updateChildren(prevTree);
});
} catch (error) {
console.error("Failed to load subdirectory:", error);
}
}
}
setExpandedFolders(newExpanded);
};
const renderSidebarItem = (item: SidebarItem, level: number = 0) => {
const isExpanded = expandedFolders.has(item.id);
const isActive = currentPath === item.path;
return (
<div key={item.id}>
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-dark-hover rounded",
isActive && "bg-primary/20 text-primary",
"text-white",
)}
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
if (
item.type === "recent" ||
item.type === "pinned" ||
item.type === "shortcut"
) {
handleContextMenu(e, item);
}
}}
>
{item.type === "folder" && (
<button
onClick={(e) => {
e.stopPropagation();
toggleFolder(item.id, item.path);
}}
className="p-0.5 hover:bg-dark-hover rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
{item.type === "folder" ? (
isExpanded ? (
<FolderOpen className="w-4 h-4" />
) : (
<Folder className="w-4 h-4" />
)
) : (
<File className="w-4 h-4" />
)}
<span className="truncate">{item.name}</span>
</div>
{item.type === "folder" && isExpanded && item.children && (
<div>
{item.children.map((child) => renderSidebarItem(child, level + 1))}
</div>
)}
</div>
);
};
const renderSection = (
title: string,
icon: React.ReactNode,
items: SidebarItem[],
) => {
if (items.length === 0) return null;
return (
<div className="mb-5">
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{icon}
{title}
</div>
<div className="space-y-0.5">
{items.map((item) => renderSidebarItem(item))}
</div>
</div>
);
};
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
return (
<>
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
recentItems,
)}
{renderSection(
t("fileManager.pinned"),
<Star className="w-3 h-3" />,
pinnedItems,
)}
{renderSection(
t("fileManager.folderShortcuts"),
<Bookmark className="w-3 h-3" />,
shortcuts,
)}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
)}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
{t("fileManager.directories")}
</div>
<div className="mt-2">
{directoryTree.map((item) => renderSidebarItem(item))}
</div>
</div>
</div>
</div>
</div>
{contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-dark-border" />
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Star className="w-4 h-4" />
</div>
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Bookmark className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
</>
)}
</>
);
}

View File

@@ -1,62 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { X, Home } from "lucide-react";
interface FileManagerTab {
id: string | number;
title: string;
}
interface FileManagerTabList {
tabs: FileManagerTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FileManagerTabList({
tabs,
activeTab,
setActiveTab,
closeTab,
onHomeClick,
}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
<Home className="w-4 h-4" />
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className="inline-flex rounded-md shadow-sm"
role="group"
>
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
>
<X className="!w-4 !h-4" strokeWidth={2} />
</Button>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
return;
}
try {
setIsLoading(true);
setError(null);
await ensureSSHConnection();
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: any) {
console.error("Failed to load files for diff:", error);
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
setError(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error:
error.message ||
errorData?.error ||
t("fileManager.unknownError"),
}),
);
}
} finally {
setIsLoading(false);
}
};
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: any) {
console.error("Failed to download file:", error);
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(error.message || t("fileManager.unknownError")),
);
}
};
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ""] || "plaintext";
};
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t("fileManager.reload")}
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg">
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={t("fileManager.downloadFile", { name: file1.name })}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={t("fileManager.downloadFile", { name: file2.name })}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
modifiedEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from "./WindowManager";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100,
}: DiffWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,380 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
interface DraggableWindowProps {
title: string;
children: React.ReactNode;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
isMaximized?: boolean;
zIndex?: number;
onFocus?: () => void;
targetSize?: { width: number; height: number };
}
export function DraggableWindow({
title,
children,
initialX = 100,
initialY = 100,
initialWidth = 600,
initialHeight = 400,
minWidth = 300,
minHeight = 200,
onClose,
onMinimize,
onMaximize,
isMaximized = false,
zIndex = 1000,
onFocus,
targetSize,
}: DraggableWindowProps) {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
const scale = Math.min(widthRatio, heightRatio);
newWidth = Math.floor(newWidth * scale);
newHeight = Math.floor(newHeight * scale);
}
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
const windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
setPosition({
x: constrainedX,
y: constrainedY,
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = sizeStart.width;
let newHeight = sizeStart.height;
let newX = windowStart.x;
let newY = windowStart.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
newX = windowStart.x - (minWidth - sizeStart.width);
}
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
newY = windowStart.y - (minHeight - sizeStart.height);
}
}
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection("");
}, []);
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
onFocus?.();
},
[isMaximized, position, size, onFocus],
);
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
return (
<div
ref={windowRef}
className={cn(
"absolute bg-card border border-border rounded-lg shadow-2xl",
"select-none overflow-hidden",
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
<div
ref={titleBarRef}
className={cn(
"flex items-center justify-between px-3 py-2",
"bg-muted/50 text-foreground border-b border-border",
"cursor-grab active:cursor-grabbing",
)}
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
>
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium truncate">{title}</span>
</div>
<div className="flex items-center gap-1">
{onMinimize && (
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
onMinimize();
}}
title={t("common.minimize")}
>
<Minus className="w-4 h-4" />
</button>
)}
{onMaximize && (
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={isMaximized ? t("common.restore") : t("common.maximize")}
>
{isMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
)}
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div
className="flex-1 overflow-hidden"
style={{ height: "calc(100% - 40px)" }}
>
{children}
</div>
{!isMaximized && (
<>
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void;
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
const mediaExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) {
console.error("Failed to load file:", error);
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000,
});
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
const errorMessage =
errorData?.error || error.message || "Unknown error";
const isFileNotFound =
(error as any).isFileNotFound ||
errorData?.fileNotFound ||
error.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
onFileNotFound(file);
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
closeWindow(windowId);
return;
} else {
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
const handleRevert = async () => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
} catch (error: any) {
console.error("Failed to load file content:", error);
toast.error(
`${t("fileManager.failedToLoadFile")}: ${error.message || t("fileManager.unknownError")}`,
);
} finally {
setIsLoading(false);
}
};
loadFileContent();
};
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent("");
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: any) {
console.error("Failed to save file:", error);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsLoading(false);
}
};
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (newContent !== content) {
autoSaveTimerRef.current = setTimeout(async () => {
try {
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000);
}
};
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
const handleDownload = async () => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.fileDownloadedSuccessfully"));
}
} catch (error: any) {
console.error("Failed to download file:", error);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`Failed to download file: ${error.message || "Unknown error"}`,
);
}
}
};
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,96 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from "../../Terminal/Terminal";
import { useWindowManager } from "./WindowManager";
import { useTranslation } from "react-i18next";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
return null;
}
const handleClose = () => {
closeWindow(windowId);
};
const handleMinimize = () => {
minimizeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
title: string;
component: React.ReactNode | ((windowId: string) => React.ReactNode);
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
isMinimized: boolean;
zIndex: number;
}
interface WindowManagerProps {
children?: React.ReactNode;
}
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
focusWindow: (id: string) => void;
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const offset = (windows.length % 5) * 20;
let adjustedX = windowData.x + offset;
let adjustedY = windowData.y + offset;
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
openWindow,
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
};
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
<div className="window-container">
{windows.map((window) => (
<div key={window.id}>
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</WindowManagerContext.Provider>
);
}
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}

View File

@@ -0,0 +1,161 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
}
}
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState((prev) => ({
...prev,
isDragging: true,
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0,
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
resetDragState,
};
}

View File

@@ -0,0 +1,92 @@
import { useState, useCallback } from "react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
setSelectedFiles(files);
}, []);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelection,
};
}

View File

@@ -82,7 +82,11 @@ export function HostManager({
{t("hosts.hostViewer")}
</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost ? t("hosts.editHost") : t("hosts.addHost")}
{editingHost
? editingHost.id
? t("hosts.editHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</TabsTrigger>
<div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="credentials">

View File

@@ -30,9 +30,14 @@ import {
getCredentials,
getSSHHosts,
updateSSHHost,
enableAutoStart,
disableAutoStart,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
interface SSHHost {
id: number;
@@ -205,15 +210,7 @@ export function HostManagerEditor({
defaultPath: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (!data.password || data.password.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (data.authType === "key") {
if (
!data.key ||
(typeof data.key === "string" && data.key.trim() === "")
@@ -343,7 +340,7 @@ export function HostManagerEditor({
if (defaultAuthType === "password") {
formData.password = cleanedHost.password || "";
} else if (defaultAuthType === "key") {
formData.key = "existing_key";
formData.key = editingHost.id ? "existing_key" : editingHost.key;
formData.keyPassword = cleanedHost.keyPassword || "";
formData.keyType = (cleanedHost.keyType as any) || "auto";
} else if (defaultAuthType === "credential") {
@@ -420,7 +417,11 @@ export function HostManagerEditor({
submitData.keyType = null;
if (data.authType === "credential") {
if (data.credentialId === "existing_credential") {
if (
data.credentialId === "existing_credential" &&
editingHost &&
editingHost.id
) {
delete submitData.credentialId;
} else {
submitData.credentialId = data.credentialId;
@@ -440,22 +441,48 @@ export function HostManagerEditor({
submitData.keyType = data.keyType;
}
if (editingHost) {
const updatedHost = await updateSSHHost(editingHost.id, submitData);
let savedHost;
if (editingHost && editingHost.id) {
savedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
if (onFormSubmit) {
onFormSubmit(updatedHost);
}
} else {
const newHost = await createSSHHost(submitData);
savedHost = await createSSHHost(submitData);
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
}
if (onFormSubmit) {
onFormSubmit(newHost);
if (savedHost && savedHost.id && data.tunnelConnections) {
const hasAutoStartTunnels = data.tunnelConnections.some(
(tunnel) => tunnel.autoStart,
);
if (hasAutoStartTunnels) {
try {
await enableAutoStart(savedHost.id);
} catch (error) {
console.warn(
`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
error,
);
toast.warning(
t("hosts.autoStartEnableFailed", { name: data.name }),
);
}
} else {
try {
await disableAutoStart(savedHost.id);
} catch (error) {
console.warn(
`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
error,
);
}
}
}
if (onFormSubmit) {
onFormSubmit(savedHost);
}
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
form.reset();
@@ -957,19 +984,35 @@ export function HostManagerEditor({
<FormItem className="mb-4">
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
<CodeMirror
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) =>
field.onChange(e.target.value)
}
onChange={(value) => field.onChange(value)}
placeholder={t(
"placeholders.pastePrivateKey",
)}
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
</FormItem>
@@ -1118,7 +1161,7 @@ export function HostManagerEditor({
<code className="bg-muted px-1 rounded inline">
sudo apt install sshpass
</code>{" "}
(Debian/Ubuntu) or the equivalent for your OS.
{t("hosts.debianUbuntuEquivalent")}
</div>
<div className="mt-2">
<strong>{t("hosts.otherInstallMethods")}</strong>
@@ -1127,7 +1170,7 @@ export function HostManagerEditor({
<code className="bg-muted px-1 rounded inline">
sudo yum install sshpass
</code>{" "}
or{" "}
{t("hosts.or")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo dnf install sshpass
</code>
@@ -1497,7 +1540,11 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingHost ? t("hosts.updateHost") : t("hosts.addHost")}
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
</footer>
</form>

View File

@@ -41,6 +41,7 @@ import {
Check,
Pencil,
FolderMinus,
Copy,
} from "lucide-react";
import type {
SSHHost,
@@ -206,6 +207,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const handleClone = (host: SSHHost) => {
if (onEditHost) {
const clonedHost = { ...host };
delete clonedHost.id;
onEditHost(clonedHost);
}
};
const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast(
t("hosts.confirmRemoveFromFolder", {
@@ -516,13 +525,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
@@ -531,7 +540,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
defaultPath: "/var/www",
},
{
name: "Database Server",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
@@ -539,7 +548,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
@@ -549,7 +558,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
@@ -557,13 +566,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
],
},
{
name: "Development Server",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: "Development",
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
@@ -677,13 +686,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
@@ -692,7 +701,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
defaultPath: "/var/www",
},
{
name: "Database Server",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
@@ -700,7 +709,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
@@ -710,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
@@ -718,13 +727,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
],
},
{
name: "Development Server",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: "Development",
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
@@ -1009,6 +1018,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<p>Export host</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleClone(host);
}}
className="h-5 w-5 p-0 text-emerald-500 hover:text-emerald-700 hover:bg-emerald-500/10"
>
<Copy className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clone host</p>
</TooltipContent>
</Tooltip>
</div>
</div>

View File

@@ -40,6 +40,7 @@ export function Server({
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
@@ -116,10 +117,12 @@ export function Server({
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setShowStatsUI(true);
}
} catch (error) {
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
} finally {
@@ -133,10 +136,8 @@ export function Server({
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
if (isVisible) {
fetchStatus();
fetchMetrics();
}
fetchStatus();
fetchMetrics();
}, 30000);
}
@@ -177,7 +178,6 @@ export function Server({
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
{/* Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
@@ -208,6 +208,7 @@ export function Server({
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (error?.response?.status === 503) {
setServerStatus("offline");
@@ -219,6 +220,7 @@ export function Server({
setServerStatus("offline");
}
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
@@ -266,184 +268,185 @@ export function Server({
</div>
<Separator className="p-0.25 w-full" />
{/* Stats */}
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
{showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{/* Memory Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number"
? `${used.toFixed(1)} GiB`
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
</div>
{/* Memory Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number"
? `${used.toFixed(1)} GiB`
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
return `Free: ${free} GiB`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
</div>
</div>
</div>
</div>
{/* Disk Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div>
{/* Disk Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* SSH Tunnels */}
{currentHostConfig?.tunnelConnections &&

View File

@@ -21,12 +21,28 @@ interface SSHTerminalProps {
showTitle?: boolean;
splitScreen?: boolean;
onClose?: () => void;
initialPath?: string;
executeCommand?: string;
}
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false, onClose },
{
hostConfig,
isVisible,
splitScreen = false,
onClose,
initialPath,
executeCommand,
},
ref,
) {
if (typeof window !== "undefined" && !(window as any).testJWT) {
(window as any).testJWT = () => {
const jwt = getCookie("jwt");
return jwt;
};
}
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -38,6 +54,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -45,6 +62,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const isUnmountingRef = useRef(false);
const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
const isConnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -56,6 +74,26 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isVisibleRef.current = isVisible;
}, [isVisible]);
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
setIsAuthenticated((prev) => {
if (prev !== isAuth) {
return isAuth;
}
return prev;
});
};
checkAuth();
const authCheckInterval = setInterval(checkAuth, 5000);
return () => clearInterval(authCheckInterval);
}, []);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === "function") {
@@ -130,11 +168,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
[terminal],
);
useEffect(() => {
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, []);
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
@@ -150,7 +183,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (
isUnmountingRef.current ||
shouldNotReconnectRef.current ||
isReconnectingRef.current
isReconnectingRef.current ||
isConnectingRef.current ||
wasDisconnectedBySSH.current
) {
return;
}
@@ -179,7 +214,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
);
reconnectTimeoutRef.current = setTimeout(() => {
if (isUnmountingRef.current || shouldNotReconnectRef.current) {
if (
isUnmountingRef.current ||
shouldNotReconnectRef.current ||
wasDisconnectedBySSH.current
) {
isReconnectingRef.current = false;
return;
}
@@ -189,6 +228,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return;
}
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn("Reconnection cancelled - no authentication token");
isReconnectingRef.current = false;
setConnectionError("Authentication required for reconnection");
return;
}
if (terminal && hostConfig) {
terminal.clear();
const cols = terminal.cols;
@@ -201,18 +248,35 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
function connectToHost(cols: number, rows: number) {
if (isConnectingRef.current) {
return;
}
isConnectingRef.current = true;
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const wsUrl = isDev
? "ws://localhost:8082"
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.error("No JWT token available for WebSocket connection");
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
isConnectingRef.current = false;
return;
}
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as any).configuredServerUrl || "http://127.0.0.1:8081";
(window as any).configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
@@ -221,6 +285,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
if (
webSocketRef.current &&
webSocketRef.current.readyState !== WebSocket.CLOSED
) {
webSocketRef.current.close();
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
@@ -252,7 +334,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.send(
JSON.stringify({
type: "connectToHost",
data: { cols, rows, hostConfig },
data: { cols, rows, hostConfig, initialPath, executeCommand },
}),
);
terminal.onData((data) => {
@@ -307,6 +389,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.clear();
}
setIsConnecting(true);
wasDisconnectedBySSH.current = false;
attemptReconnection();
return;
}
@@ -315,6 +398,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} else if (msg.type === "connected") {
setIsConnected(true);
setIsConnecting(false);
isConnectingRef.current = false;
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -330,9 +414,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (terminal) {
terminal.clear();
}
setIsConnecting(true);
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection();
setIsConnecting(false);
if (onClose) {
onClose();
}
}
} catch (error) {
@@ -342,27 +426,45 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener("close", (event) => {
setIsConnected(false);
isConnectingRef.current = false;
if (terminal) {
terminal.clear();
}
setIsConnecting(true);
if (event.code === 1008) {
console.error("WebSocket authentication failed:", event.reason);
setConnectionError("Authentication failed - please re-login");
setIsConnecting(false);
shouldNotReconnectRef.current = true;
localStorage.removeItem("jwt");
toast.error("Authentication failed. Please log in again.");
return;
}
setIsConnecting(false);
if (
!wasDisconnectedBySSH.current &&
!isUnmountingRef.current &&
!shouldNotReconnectRef.current
) {
wasDisconnectedBySSH.current = false;
attemptReconnection();
}
});
ws.addEventListener("error", (event) => {
setIsConnected(false);
isConnectingRef.current = false;
setConnectionError(t("terminal.websocketError"));
if (terminal) {
terminal.clear();
}
setIsConnecting(true);
setIsConnecting(false);
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
wasDisconnectedBySSH.current = false;
attemptReconnection();
}
});
@@ -399,7 +501,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
if (!terminal || !xtermRef.current) return;
terminal.options = {
cursorBlink: true,
@@ -407,7 +509,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
scrollback: 10000,
fontSize: 14,
fontFamily:
'"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
'"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
theme: { background: "#18181b", foreground: "#f7f7f7" },
allowTransparency: true,
convertEol: true,
@@ -452,6 +554,45 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
};
element?.addEventListener("contextmenu", handleContextMenu);
const handleMacKeyboard = (e: KeyboardEvent) => {
const isMacOS =
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
if (!isMacOS) return;
if (e.altKey && !e.metaKey && !e.ctrlKey) {
const keyMappings: { [key: string]: string } = {
"7": "|",
"2": "€",
"8": "[",
"9": "]",
l: "@",
L: "@",
Digit7: "|",
Digit2: "€",
Digit8: "[",
Digit9: "]",
KeyL: "@",
};
const char = keyMappings[e.key] || keyMappings[e.code];
if (char) {
e.preventDefault();
e.stopPropagation();
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
return false;
}
}
};
element?.addEventListener("keydown", handleMacKeyboard, true);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
@@ -459,34 +600,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 100);
}, 150);
});
resizeObserver.observe(xtermRef.current);
const readyFonts =
(document as any).fonts?.ready instanceof Promise
? (document as any).fonts.ready
: Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
if (terminal && !splitScreen) {
terminal.focus();
}
}, 0);
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}, 300);
});
setVisible(true);
return () => {
isUnmountingRef.current = true;
@@ -495,6 +614,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
setIsConnecting(false);
resizeObserver.disconnect();
element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (reconnectTimeoutRef.current)
@@ -507,7 +627,46 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
}, [xtermRef, terminal]);
useEffect(() => {
if (!terminal || !hostConfig || !visible) return;
if (isConnected || isConnecting) return;
setIsConnecting(true);
const readyFonts =
(document as any).fonts?.ready instanceof Promise
? (document as any).fonts.ready
: Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen) {
terminal.focus();
}
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
return;
}
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}, 200);
});
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
@@ -541,11 +700,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}, [splitScreen, isVisible, terminal]);
return (
<div className="h-full w-full m-1 relative">
{/* Terminal */}
<div className="h-full w-full relative">
<div
ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"} overflow-hidden`}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();
@@ -553,7 +711,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}}
/>
{/* Connecting State */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3">
@@ -570,7 +727,7 @@ const style = document.createElement("style");
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
/* Load NerdFonts locally */
/* Load NerdFonts locally with fallback handling */
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
@@ -595,6 +752,15 @@ style.innerHTML = `
font-display: swap;
}
/* Fallback fonts for when custom fonts fail to load */
@font-face {
font-family: 'Terminal Fallback';
src: local('SF Mono'), local('Monaco'), local('Consolas'), local('Liberation Mono'), local('Courier New');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
@@ -619,7 +785,7 @@ style.innerHTML = `
}
.xterm .xterm-screen {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
font-variant-ligatures: contextual;
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
@@ -15,6 +16,7 @@ import type {
} from "../../../types/index.js";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const { t } = useTranslation();
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
@@ -114,7 +116,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
const interval = setInterval(fetchTunnelStatuses, 5000);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
@@ -137,7 +139,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
);
if (!endpointHost) {
throw new Error("Endpoint host not found");
throw new Error(t("tunnels.endpointHostNotFound"));
}
const tunnelConfig = {

View File

@@ -237,7 +237,7 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
@@ -246,9 +246,9 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
{t("tunnels.githubIssue")}
</a>{" "}
for help.
{t("tunnels.forHelp")}.
</div>
</>
)}
@@ -471,7 +471,7 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
@@ -480,9 +480,9 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
{t("tunnels.githubIssue")}
</a>{" "}
for help.
{t("tunnels.forHelp")}.
</div>
</>
)}

View File

@@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { VersionCheckModal } from "@/components/ui/version-check-modal.tsx";
import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
function AppContent() {
@@ -22,34 +23,37 @@ function AppContent() {
const [username, setUsername] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [showVersionCheck, setShowVersionCheck] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const { currentTab, tabs } = useTabs();
useEffect(() => {
const checkAuth = () => {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
})
.catch((err) => {
setAuthLoading(true);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
if (!meRes.data_unlocked) {
console.warn("User data is locked - re-authentication required");
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
}
})
.finally(() => setAuthLoading(false));
};
checkAuth();
@@ -92,7 +96,15 @@ function AppContent() {
return (
<div>
{!isAuthenticated && !authLoading && (
{showVersionCheck && (
<VersionCheckModal
onDismiss={() => setShowVersionCheck(false)}
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={isAuthenticated}
/>
)}
{!isAuthenticated && !authLoading && !showVersionCheck && (
<div>
<div
className="absolute inset-0"
@@ -112,7 +124,7 @@ function AppContent() {
</div>
)}
{!isAuthenticated && !authLoading && (
{!isAuthenticated && !authLoading && !showVersionCheck && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
@@ -131,11 +143,12 @@ function AppContent() {
isAdmin={isAdmin}
username={username}
>
{showTerminalView && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AppView isTopbarOpen={isTopbarOpen} />
</div>
)}
<div
className="h-screen w-full visible pointer-events-auto static overflow-hidden"
style={{ display: showTerminalView ? "block" : "none" }}
>
<AppView isTopbarOpen={isTopbarOpen} />
</div>
{showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">

View File

@@ -146,7 +146,7 @@ export function ServerConfig({
<Input
id="server-url"
type="text"
placeholder="http://localhost:8081 or https://your-server.com"
placeholder="http://localhost:30001 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="flex-1 h-10"

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
import { HomepageAlertManager } from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
@@ -45,14 +46,19 @@ export function Homepage({
.then(([meRes]) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
setIsAdmin(false);
setUsername(null);
setUserId(null);
if (err?.response?.data?.error?.includes("Database")) {
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
@@ -150,6 +156,8 @@ export function Homepage({
</div>
</div>
)}
<HomepageAlertManager userId={userId} loggedIn={loggedIn} />
</>
);
}

View File

@@ -27,14 +27,11 @@ export function HomepageAlertManager({
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const response = await getUserAlerts();
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
@@ -65,10 +62,8 @@ export function HomepageAlertManager({
};
const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
try {
await dismissAlert(userId, alertId);
await dismissAlert(alertId);
setAlerts((prev) => {
const newAlerts = prev.filter((alert) => alert.id !== alertId);

File diff suppressed because it is too large Load Diff

View File

@@ -140,10 +140,10 @@ export function AppView({
const isFileManagerTab = mainTab.type === "file_manager";
styles[mainTab.id] = {
position: "absolute",
top: isFileManagerTab ? 0 : 2,
left: isFileManagerTab ? 0 : 2,
right: isFileManagerTab ? 0 : 2,
bottom: isFileManagerTab ? 0 : 2,
top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4,
zIndex: 20,
display: "block",
pointerEvents: "auto",
@@ -156,10 +156,10 @@ export function AppView({
if (rect && parentRect) {
styles[t.id] = {
position: "absolute",
top: rect.top - parentRect.top + HEADER_H + 2,
left: rect.left - parentRect.left + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
top: rect.top - parentRect.top + HEADER_H + 4,
left: rect.left - parentRect.left + 4,
width: rect.width - 8,
height: rect.height - HEADER_H - 8,
zIndex: 20,
display: "block",
pointerEvents: "auto",

View File

@@ -46,7 +46,7 @@ export function Host({ host }: HostProps): React.ReactElement {
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
intervalId = window.setInterval(fetchStatus, 30000);
return () => {
cancelled = true;

View File

@@ -1,7 +1,12 @@
import React, { useState } from "react";
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getCookie, setCookie, isElectron } from "@/ui/main-axios.ts";
import {
getCookie,
setCookie,
isElectron,
logoutUser,
} from "@/ui/main-axios.ts";
import {
Sidebar,
@@ -66,14 +71,19 @@ interface SidebarProps {
children?: React.ReactNode;
}
function handleLogout() {
if (isElectron()) {
localStorage.removeItem("jwt");
} else {
document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
async function handleLogout() {
try {
await logoutUser();
window.location.reload();
if (isElectron()) {
localStorage.removeItem("jwt");
}
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
window.location.reload();
}
}
export function LeftSidebar({
@@ -361,8 +371,8 @@ export function LeftSidebar({
</div>
{hostsError && (
<div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>

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