v1.7.0 #318
129
.dockerignore
Normal 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
|
||||
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/docker-image.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/electron-build.yml
vendored
@@ -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
@@ -25,3 +25,5 @@ dist-ssr
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
/ssl/
|
||||
.env
|
||||
|
||||
@@ -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 |
|
||||
|
||||
45
README.md
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
Disabling certificate validation ( 
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...");
|
||||
});
|
||||
|
||||
@@ -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
45
package.json
@@ -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
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 305 KiB |
BIN
repo-images/Image 6.png
Normal file
|
After Width: | Height: | Size: 360 KiB |
103
scripts/enable-ssl.sh
Normal 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
@@ -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 "$@"
|
||||
@@ -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 };
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
300
src/backend/utils/auth-manager.ts
Normal 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 };
|
||||
280
src/backend/utils/auto-ssl-setup.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
284
src/backend/utils/data-crypto.ts
Normal 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 };
|
||||
400
src/backend/utils/database-file-encryption.ts
Normal 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 };
|
||||
404
src/backend/utils/database-migration.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/backend/utils/database-save-trigger.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
108
src/backend/utils/field-crypto.ts
Normal 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 };
|
||||
243
src/backend/utils/lazy-field-encryption.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
157
src/backend/utils/simple-db-ops.ts
Normal 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 };
|
||||
418
src/backend/utils/ssh-key-utils.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
263
src/backend/utils/system-crypto.ts
Normal 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 };
|
||||
443
src/backend/utils/user-crypto.ts
Normal 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 };
|
||||
281
src/backend/utils/user-data-export.ts
Normal 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 };
|
||||
434
src/backend/utils/user-data-import.ts
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
109
src/components/ui/version-alert.tsx
Normal 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;
|
||||
}
|
||||
187
src/components/ui/version-check-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "最大:100MB(JSON)/ 200MB(二进制)",
|
||||
"maxFileSize": "最大:1GB(JSON)/ 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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
486
src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1397
src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
545
src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
335
src/ui/Desktop/Apps/File Manager/components/DiffViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/ui/Desktop/Apps/File Manager/components/DiffWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
380
src/ui/Desktop/Apps/File Manager/components/DraggableWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1460
src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx
Normal file
409
src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
138
src/ui/Desktop/Apps/File Manager/components/WindowManager.tsx
Normal 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;
|
||||
}
|
||||
161
src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
92
src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
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.