Implement Executable File Detection & Terminal Integration + i18n Improvements #252
+257
@@ -0,0 +1,257 @@
|
|||||||
|
# Security Guide for Termix
|
||||||
|
|
||||||
|
## Database Encryption
|
||||||
|
|
||||||
|
Termix implements AES-256-GCM encryption for sensitive data stored in the database. This protects SSH credentials, passwords, and authentication tokens from unauthorized access.
|
||||||
|
|
||||||
|
### Encrypted Fields
|
||||||
|
|
||||||
|
The following database fields are automatically encrypted:
|
||||||
|
|
||||||
|
**Users Table:**
|
||||||
|
- `password_hash` - User password hashes
|
||||||
|
- `client_secret` - OIDC client secrets
|
||||||
|
- `totp_secret` - 2FA authentication seeds
|
||||||
|
- `totp_backup_codes` - 2FA backup codes
|
||||||
|
|
||||||
|
**SSH Data Table:**
|
||||||
|
- `password` - SSH connection passwords
|
||||||
|
- `key` - SSH private keys
|
||||||
|
- `keyPassword` - SSH private key passphrases
|
||||||
|
|
||||||
|
**SSH Credentials Table:**
|
||||||
|
- `password` - Stored SSH passwords
|
||||||
|
- `privateKey` - SSH private keys
|
||||||
|
- `keyPassword` - SSH private key passphrases
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
#### Required Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Encryption master key (REQUIRED)
|
||||||
|
DB_ENCRYPTION_KEY=your-very-strong-encryption-key-32-chars-minimum
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ CRITICAL:** The encryption key must be:
|
||||||
|
- At least 16 characters long (32+ recommended)
|
||||||
|
|
|||||||
|
- Cryptographically random
|
||||||
|
- Unique per installation
|
||||||
|
- Safely backed up
|
||||||
|
|
||||||
|
#### Optional Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable/disable encryption (default: true)
|
||||||
|
ENCRYPTION_ENABLED=true
|
||||||
|
|
||||||
|
# Reject unencrypted data (default: false)
|
||||||
|
FORCE_ENCRYPTION=false
|
||||||
|
|
||||||
|
# Auto-encrypt legacy data (default: true)
|
||||||
|
MIGRATE_ON_ACCESS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
#### 1. Generate Encryption Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure random key (Linux/macOS)
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Or using Node.js
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Set Environment Variable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to your .env file
|
||||||
|
echo "DB_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Validate Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test encryption setup
|
||||||
|
npm run test:encryption
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration from Unencrypted Database
|
||||||
|
|
||||||
|
If you have an existing Termix installation with unencrypted data:
|
||||||
|
|
||||||
|
#### 1. Backup Your Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backup before migration
|
||||||
|
cp ./db/data/db.sqlite ./db/data/db-backup-$(date +%Y%m%d-%H%M%S).sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Run Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set encryption key
|
||||||
|
export DB_ENCRYPTION_KEY="your-secure-key-here"
|
||||||
|
|
||||||
|
# Test migration (dry run)
|
||||||
|
npm run migrate:encryption -- --dry-run
|
||||||
|
|
||||||
|
# Run actual migration
|
||||||
|
npm run migrate:encryption
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Verify Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check encryption status
|
||||||
|
curl http://localhost:8081/encryption/status
|
||||||
|
|
||||||
|
# Test application functionality
|
||||||
|
npm run test:encryption production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
#### Key Management
|
||||||
|
|
||||||
|
1. **Generate unique keys** for each installation
|
||||||
|
2. **Store keys securely** (use environment variables, not config files)
|
||||||
|
3. **Backup keys safely** (encrypted backups in secure locations)
|
||||||
|
4. **Rotate keys periodically** (implement key rotation schedule)
|
||||||
|
|
||||||
|
#### Deployment Security
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production Docker example
|
||||||
|
docker run -d \
|
||||||
|
-e DB_ENCRYPTION_KEY="$(cat /secure/location/encryption.key)" \
|
||||||
|
-e ENCRYPTION_ENABLED=true \
|
||||||
|
-e FORCE_ENCRYPTION=true \
|
||||||
|
-v termix-data:/app/data \
|
||||||
|
ghcr.io/lukegus/termix:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### File System Protection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Secure database directory permissions
|
||||||
|
chmod 700 ./db/data/
|
||||||
|
chmod 600 ./db/data/db.sqlite
|
||||||
|
|
||||||
|
# Use encrypted storage if possible
|
||||||
|
# Consider full disk encryption for production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring and Alerting
|
||||||
|
|
||||||
|
#### Health Checks
|
||||||
|
|
||||||
|
The encryption system provides health check endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check encryption status
|
||||||
|
GET /encryption/status
|
||||||
|
|
||||||
|
# Response format:
|
||||||
|
{
|
||||||
|
"encryption": {
|
||||||
|
"enabled": true,
|
||||||
|
"configValid": true,
|
||||||
|
"forceEncryption": false,
|
||||||
|
"migrateOnAccess": true
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"isEncryptionEnabled": true,
|
||||||
|
"migrationCompleted": true,
|
||||||
|
"migrationDate": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Log Monitoring
|
||||||
|
|
||||||
|
Monitor logs for encryption-related events:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Encryption initialization
|
||||||
|
"Database encryption initialized successfully"
|
||||||
|
|
||||||
|
# Migration events
|
||||||
|
"Migration completed for table: users"
|
||||||
|
|
||||||
|
# Security warnings
|
||||||
|
"DB_ENCRYPTION_KEY not set, using default (INSECURE)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### Common Issues
|
||||||
|
|
||||||
|
**1. "Decryption failed" errors**
|
||||||
|
- Verify `DB_ENCRYPTION_KEY` is correct
|
||||||
|
- Check if database was corrupted
|
||||||
|
- Restore from backup if necessary
|
||||||
|
|
||||||
|
**2. Performance issues**
|
||||||
|
- Encryption adds ~1ms per operation
|
||||||
|
- Consider disabling `MIGRATE_ON_ACCESS` after migration
|
||||||
|
- Monitor CPU usage during large migrations
|
||||||
|
|
||||||
|
**3. Key rotation**
|
||||||
|
```bash
|
||||||
|
# Generate new key
|
||||||
|
NEW_KEY=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
# Update configuration
|
||||||
|
# Note: Requires re-encryption of all data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compliance Notes
|
||||||
|
|
||||||
|
This encryption implementation helps meet requirements for:
|
||||||
|
|
||||||
|
- **GDPR** - Personal data protection
|
||||||
|
- **SOC 2** - Data security controls
|
||||||
|
- **PCI DSS** - Sensitive data protection
|
||||||
|
- **HIPAA** - Healthcare data encryption (if applicable)
|
||||||
|
|
||||||
|
### Security Limitations
|
||||||
|
|
||||||
|
**What this protects against:**
|
||||||
|
- Database file theft
|
||||||
|
- Disk access by unauthorized users
|
||||||
|
- Data breaches from file system access
|
||||||
|
|
||||||
|
**What this does NOT protect against:**
|
||||||
|
- Application-level vulnerabilities
|
||||||
|
- Memory dumps while application is running
|
||||||
|
- Attacks against the running application
|
||||||
|
- Social engineering attacks
|
||||||
|
|
||||||
|
### Emergency Procedures
|
||||||
|
|
||||||
|
#### Lost Encryption Key
|
||||||
|
|
||||||
|
⚠️ **Data is unrecoverable without the encryption key**
|
||||||
|
|
||||||
|
1. Check all backup locations
|
||||||
|
2. Restore from unencrypted backup if available
|
||||||
|
3. Contact system administrators
|
||||||
|
|
||||||
|
#### Suspected Key Compromise
|
||||||
|
|
||||||
|
1. **Immediately** generate new encryption key
|
||||||
|
2. Take application offline
|
||||||
|
3. Re-encrypt all sensitive data with new key
|
||||||
|
4. Investigate compromise source
|
||||||
|
5. Update security procedures
|
||||||
|
|
||||||
|
### Support
|
||||||
|
|
||||||
|
For security-related questions:
|
||||||
|
- Open issue: [GitHub Issues](https://github.com/LukeGus/Termix/issues)
|
||||||
|
- Discord: [Termix Community](https://discord.gg/jVQGdvHDrf)
|
||||||
|
|
||||||
|
**Do not share encryption keys or sensitive debugging information in public channels.**
|
||||||
+179
-1
@@ -1,6 +1,7 @@
|
|||||||
const { app, BrowserWindow, shell, ipcMain } = require("electron");
|
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
|
|
||||||
@@ -317,8 +318,185 @@ app.on("activate", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================== 拖拽功能实现 ==================
|
||||||
|
|
||||||
|
// 临时文件管理
|
||||||
|
const tempFiles = new Map(); // 存储临时文件路径映射
|
||||||
|
|
||||||
|
// 创建临时文件
|
||||||
|
ipcMain.handle("create-temp-file", async (event, fileData) => {
|
||||||
|
try {
|
||||||
|
const { fileName, content, encoding = 'base64' } = fileData;
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
const tempDir = path.join(os.tmpdir(), 'termix-drag-files');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成临时文件路径
|
||||||
|
const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`);
|
||||||
|
|
||||||
|
// 写入文件内容
|
||||||
|
if (encoding === 'base64') {
|
||||||
|
const buffer = Buffer.from(content, 'base64');
|
||||||
|
fs.writeFileSync(tempFilePath, buffer);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(tempFilePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录临时文件
|
||||||
|
tempFiles.set(tempId, {
|
||||||
|
path: tempFilePath,
|
||||||
|
fileName: fileName,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created temp file: ${tempFilePath}`);
|
||||||
|
return { success: true, tempId, path: tempFilePath };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating temp file:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始拖拽到桌面
|
||||||
|
ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => {
|
||||||
|
try {
|
||||||
|
const tempFile = tempFiles.get(tempId);
|
||||||
|
if (!tempFile) {
|
||||||
|
throw new Error("Temporary file not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用Electron的startDrag API
|
||||||
|
const iconPath = path.join(__dirname, "..", "public", "icon.png");
|
||||||
|
const iconExists = fs.existsSync(iconPath);
|
||||||
|
|
||||||
|
mainWindow.webContents.startDrag({
|
||||||
|
file: tempFile.path,
|
||||||
|
icon: iconExists ? iconPath : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Started drag for: ${tempFile.path}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting drag:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
ipcMain.handle("cleanup-temp-file", async (event, tempId) => {
|
||||||
|
try {
|
||||||
|
const tempFile = tempFiles.get(tempId);
|
||||||
|
if (tempFile && fs.existsSync(tempFile.path)) {
|
||||||
|
fs.unlinkSync(tempFile.path);
|
||||||
|
tempFiles.delete(tempId);
|
||||||
|
console.log(`Cleaned up temp file: ${tempFile.path}`);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cleaning up temp file:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量清理过期临时文件(5分钟过期)
|
||||||
|
const cleanupExpiredTempFiles = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 5 * 60 * 1000; // 5分钟
|
||||||
|
|
||||||
|
for (const [tempId, tempFile] of tempFiles.entries()) {
|
||||||
|
if (now - tempFile.createdAt > maxAge) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempFile.path)) {
|
||||||
|
fs.unlinkSync(tempFile.path);
|
||||||
|
}
|
||||||
|
tempFiles.delete(tempId);
|
||||||
|
console.log(`Auto-cleaned expired temp file: ${tempFile.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error auto-cleaning temp file:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每分钟清理一次过期临时文件
|
||||||
|
setInterval(cleanupExpiredTempFiles, 60 * 1000);
|
||||||
|
|
||||||
|
// 创建临时文件夹拖拽支持
|
||||||
|
ipcMain.handle("create-temp-folder", async (event, folderData) => {
|
||||||
|
try {
|
||||||
|
const { folderName, files } = folderData;
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
const tempDir = path.join(os.tmpdir(), 'termix-drag-folders');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`);
|
||||||
|
|
||||||
|
// 递归创建文件夹结构
|
||||||
|
const createFolderStructure = (basePath, fileList) => {
|
||||||
|
for (const file of fileList) {
|
||||||
|
const fullPath = path.join(basePath, file.relativePath);
|
||||||
|
const dirPath = path.dirname(fullPath);
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
if (file.encoding === 'base64') {
|
||||||
|
const buffer = Buffer.from(file.content, 'base64');
|
||||||
|
fs.writeFileSync(fullPath, buffer);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(fullPath, file.content, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(tempFolderPath, { recursive: true });
|
||||||
|
createFolderStructure(tempFolderPath, files);
|
||||||
|
|
||||||
|
// 记录临时文件夹
|
||||||
|
tempFiles.set(tempId, {
|
||||||
|
path: tempFolderPath,
|
||||||
|
fileName: folderName,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isFolder: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created temp folder: ${tempFolderPath}`);
|
||||||
|
return { success: true, tempId, path: tempFolderPath };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating temp folder:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
console.log("App is quitting...");
|
console.log("App is quitting...");
|
||||||
|
|
||||||
|
// 清理所有临时文件
|
||||||
|
for (const [tempId, tempFile] of tempFiles.entries()) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempFile.path)) {
|
||||||
|
if (tempFile.isFolder) {
|
||||||
|
fs.rmSync(tempFile.path, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(tempFile.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cleaning up temp file on quit:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempFiles.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
isDev: process.env.NODE_ENV === "development",
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||||
|
|
||||||
|
// ================== 拖拽API ==================
|
||||||
|
|
||||||
|
// 创建临时文件用于拖拽
|
||||||
|
createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData),
|
||||||
|
|
||||||
|
// 创建临时文件夹用于拖拽
|
||||||
|
createTempFolder: (folderData) => ipcRenderer.invoke("create-temp-folder", folderData),
|
||||||
|
|
||||||
|
// 开始拖拽到桌面
|
||||||
|
startDragToDesktop: (dragData) => ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.IS_ELECTRON = true;
|
window.IS_ELECTRON = true;
|
||||||
|
|||||||
Generated
+131
-4
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jose": "^5.2.3",
|
"jose": "^5.2.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"react-simple-keyboard": "^3.8.120",
|
"react-simple-keyboard": "^3.8.120",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
@@ -2949,6 +2953,29 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@monaco-editor/loader": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"state-local": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@monaco-editor/react": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/loader": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"monaco-editor": ">= 0.25.0 < 1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -5149,6 +5176,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jszip": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/keyv": {
|
"node_modules/@types/keyv": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||||
@@ -5310,6 +5346,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/verror": {
|
"node_modules/@types/verror": {
|
||||||
"version": "1.10.11",
|
"version": "1.10.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
|
||||||
@@ -7588,7 +7631,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
@@ -10635,6 +10677,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -10930,7 +10978,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/isbinaryfile": {
|
"node_modules/isbinaryfile": {
|
||||||
@@ -11194,6 +11241,48 @@
|
|||||||
"extsprintf": "^1.2.0"
|
"extsprintf": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/junk": {
|
"node_modules/junk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
|
||||||
@@ -11296,6 +11385,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@@ -12233,6 +12331,16 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.53.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz",
|
||||||
|
"integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz",
|
||||||
@@ -12740,7 +12848,6 @@
|
|||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
@@ -13371,7 +13478,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
"node_modules/progress": {
|
||||||
@@ -13663,6 +13769,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||||
@@ -14432,6 +14547,12 @@
|
|||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -14881,6 +15002,12 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/state-local": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
+7
-1
@@ -18,10 +18,13 @@
|
|||||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && 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-portable": "npm run build && electron-builder --win --dir",
|
||||||
"build:win-installer": "npm run build && electron-builder --win --publish=never",
|
"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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
@@ -71,6 +75,7 @@
|
|||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jose": "^5.2.3",
|
"jose": "^5.2.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
@@ -81,6 +86,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"react-simple-keyboard": "^3.8.120",
|
"react-simple-keyboard": "^3.8.120",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
|
import multer from "multer";
|
||||||
import userRoutes from "./routes/users.js";
|
import userRoutes from "./routes/users.js";
|
||||||
import sshRoutes from "./routes/ssh.js";
|
import sshRoutes from "./routes/ssh.js";
|
||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
@@ -10,6 +11,11 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||||
|
import { DatabaseEncryption } from "../utils/database-encryption.js";
|
||||||
|
import { EncryptionMigration } from "../utils/encryption-migration.js";
|
||||||
|
import { DatabaseMigration } from "../utils/database-migration.js";
|
||||||
|
import { DatabaseSQLiteExport } from "../utils/database-sqlite-export.js";
|
||||||
|
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
@@ -25,6 +31,33 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Configure multer for file uploads
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, 'uploads/');
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// Preserve original filename with timestamp prefix to avoid conflicts
|
||||||
|
const timestamp = Date.now();
|
||||||
|
cb(null, `${timestamp}-${file.originalname}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: 100 * 1024 * 1024, // 100MB limit
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Allow SQLite files
|
||||||
|
if (file.originalname.endsWith('.termix-export.sqlite') || file.originalname.endsWith('.sqlite')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only .termix-export.sqlite files are allowed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -255,6 +288,336 @@ app.get("/releases/rss", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/encryption/status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const detailedStatus = await DatabaseEncryption.getDetailedStatus();
|
||||||
|
const migrationStatus = await EncryptionMigration.checkMigrationStatus();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
encryption: detailedStatus,
|
||||||
|
migration: migrationStatus
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Failed to get encryption status", error, {
|
||||||
|
operation: "encryption_status"
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get encryption status" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/encryption/initialize", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { EncryptionKeyManager } = await import("../utils/encryption-key-manager.js");
|
||||||
|
const keyManager = EncryptionKeyManager.getInstance();
|
||||||
|
|
||||||
|
const newKey = await keyManager.generateNewKey();
|
||||||
|
await DatabaseEncryption.initialize({ masterPassword: newKey });
|
||||||
|
|
||||||
|
apiLogger.info("Encryption initialized via API", {
|
||||||
|
operation: "encryption_init_api"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Encryption initialized successfully",
|
||||||
|
keyPreview: newKey.substring(0, 8) + "..."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Failed to initialize encryption", error, {
|
||||||
|
operation: "encryption_init_api_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to initialize encryption" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/encryption/migrate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { dryRun = false } = req.body;
|
||||||
|
|
||||||
|
const migration = new EncryptionMigration({
|
||||||
|
dryRun,
|
||||||
|
backupEnabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
apiLogger.info("Starting encryption migration (dry run)", {
|
||||||
|
operation: "encryption_migrate_dry_run"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Dry run mode - no changes made",
|
||||||
|
dryRun: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apiLogger.info("Starting encryption migration", {
|
||||||
|
operation: "encryption_migrate"
|
||||||
|
});
|
||||||
|
|
||||||
|
await migration.runMigration();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Migration completed successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Migration failed", error, {
|
||||||
|
operation: "encryption_migrate_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Migration failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/encryption/regenerate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
await DatabaseEncryption.reinitializeWithNewKey();
|
||||||
|
|
||||||
|
apiLogger.warn("Encryption key regenerated via API", {
|
||||||
|
operation: "encryption_regenerate_api"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "New encryption key generated",
|
||||||
|
warning: "All encrypted data must be re-encrypted"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Failed to regenerate encryption key", error, {
|
||||||
|
operation: "encryption_regenerate_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to regenerate encryption key" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database migration and backup endpoints
|
||||||
|
app.post("/database/export", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customPath } = req.body;
|
||||||
|
|
||||||
|
apiLogger.info("Starting SQLite database export via API", {
|
||||||
|
operation: "database_sqlite_export_api",
|
||||||
|
customPath: !!customPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportPath = await DatabaseSQLiteExport.exportDatabase(customPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Database exported successfully as SQLite",
|
||||||
|
exportPath,
|
||||||
|
size: fs.statSync(exportPath).size,
|
||||||
|
format: "sqlite"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("SQLite database export failed", error, {
|
||||||
|
operation: "database_sqlite_export_api_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "SQLite database export failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database/import", upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: "No file uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { backupCurrent = "true" } = req.body;
|
||||||
|
const backupCurrentBool = backupCurrent === "true";
|
||||||
|
const importPath = req.file.path;
|
||||||
|
|
||||||
|
apiLogger.info("Starting SQLite database import via API (additive mode)", {
|
||||||
|
operation: "database_sqlite_import_api",
|
||||||
|
importPath,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
mode: "additive",
|
||||||
|
backupCurrent: backupCurrentBool
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate export file first
|
||||||
|
// Check file extension using original filename
|
||||||
|
if (!req.file.originalname.endsWith('.termix-export.sqlite')) {
|
||||||
|
// Clean up uploaded file
|
||||||
|
fs.unlinkSync(importPath);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid SQLite export file",
|
||||||
|
details: ["File must have .termix-export.sqlite extension"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = DatabaseSQLiteExport.validateExportFile(importPath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
// Clean up uploaded file
|
||||||
|
fs.unlinkSync(importPath);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid SQLite export file",
|
||||||
|
details: validation.errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DatabaseSQLiteExport.importDatabase(importPath, {
|
||||||
|
replaceExisting: false, // Always use additive mode
|
||||||
|
backupCurrent: backupCurrentBool
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up uploaded file
|
||||||
|
fs.unlinkSync(importPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: result.success,
|
||||||
|
message: result.success ? "SQLite database imported successfully" : "SQLite database import completed with errors",
|
||||||
|
imported: result.imported,
|
||||||
|
errors: result.errors,
|
||||||
|
warnings: result.warnings,
|
||||||
|
format: "sqlite"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up uploaded file if it exists
|
||||||
|
if (req.file?.path) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
apiLogger.warn("Failed to clean up uploaded file", {
|
||||||
|
operation: "file_cleanup_failed",
|
||||||
|
filePath: req.file.path,
|
||||||
|
error: cleanupError instanceof Error ? cleanupError.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiLogger.error("SQLite database import failed", error, {
|
||||||
|
operation: "database_sqlite_import_api_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "SQLite database import failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/database/export/:exportPath/info", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { exportPath } = req.params;
|
||||||
|
const decodedPath = decodeURIComponent(exportPath);
|
||||||
|
|
||||||
|
const validation = DatabaseSQLiteExport.validateExportFile(decodedPath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid SQLite export file",
|
||||||
|
details: validation.errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
valid: true,
|
||||||
|
metadata: validation.metadata,
|
||||||
|
format: "sqlite"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Failed to get SQLite export info", error, {
|
||||||
|
operation: "sqlite_export_info_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get SQLite export information" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database/backup", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customPath } = req.body;
|
||||||
|
|
||||||
|
apiLogger.info("Creating encrypted database backup via API", {
|
||||||
|
operation: "database_backup_api"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import required modules
|
||||||
|
const { databasePaths, getMemoryDatabaseBuffer } = await import("./db/index.js");
|
||||||
|
|
||||||
|
// Get current in-memory database as buffer
|
||||||
|
const dbBuffer = getMemoryDatabaseBuffer();
|
||||||
|
|
||||||
|
// Create backup directory
|
||||||
|
const backupDir = customPath || path.join(databasePaths.directory, 'backups');
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backup filename with timestamp
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
||||||
|
const backupPath = path.join(backupDir, backupFileName);
|
||||||
|
|
||||||
|
// Create encrypted backup directly from memory buffer
|
||||||
|
DatabaseFileEncryption.encryptDatabaseFromBuffer(dbBuffer, backupPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Encrypted backup created successfully",
|
||||||
|
backupPath,
|
||||||
|
size: fs.statSync(backupPath).size
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Database backup failed", error, {
|
||||||
|
operation: "database_backup_api_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Database backup failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database/restore", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { backupPath, targetPath } = req.body;
|
||||||
|
|
||||||
|
if (!backupPath) {
|
||||||
|
return res.status(400).json({ error: "Backup path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
apiLogger.info("Restoring database from backup via API", {
|
||||||
|
operation: "database_restore_api",
|
||||||
|
backupPath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate backup file
|
||||||
|
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(backupPath)) {
|
||||||
|
return res.status(400).json({ error: "Invalid encrypted backup file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hardware compatibility
|
||||||
|
if (!DatabaseFileEncryption.validateHardwareCompatibility(backupPath)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Hardware fingerprint mismatch",
|
||||||
|
message: "This backup was created on different hardware and cannot be restored"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredPath = DatabaseFileEncryption.restoreFromEncryptedBackup(backupPath, targetPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Database restored successfully",
|
||||||
|
restoredPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
apiLogger.error("Database restore failed", error, {
|
||||||
|
operation: "database_restore_api_failed"
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Database restore failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use("/users", userRoutes);
|
app.use("/users", userRoutes);
|
||||||
app.use("/ssh", sshRoutes);
|
app.use("/ssh", sshRoutes);
|
||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
@@ -278,7 +641,49 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const PORT = 8081;
|
const PORT = 8081;
|
||||||
app.listen(PORT, () => {
|
|
||||||
|
async function initializeEncryption() {
|
||||||
|
try {
|
||||||
|
databaseLogger.info("Initializing database encryption...", {
|
||||||
|
operation: "encryption_init"
|
||||||
|
});
|
||||||
|
|
||||||
|
await DatabaseEncryption.initialize({
|
||||||
|
encryptionEnabled: process.env.ENCRYPTION_ENABLED !== 'false',
|
||||||
|
forceEncryption: process.env.FORCE_ENCRYPTION === 'true',
|
||||||
|
migrateOnAccess: process.env.MIGRATE_ON_ACCESS !== 'false'
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await DatabaseEncryption.getDetailedStatus();
|
||||||
|
if (status.configValid && status.key.keyValid) {
|
||||||
|
databaseLogger.success("Database encryption initialized successfully", {
|
||||||
|
operation: "encryption_init_complete",
|
||||||
|
enabled: status.enabled,
|
||||||
|
keyId: status.key.keyId,
|
||||||
|
hasStoredKey: status.key.hasKey
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
databaseLogger.error("Database encryption configuration invalid", undefined, {
|
||||||
|
operation: "encryption_init_failed",
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to initialize database encryption", error, {
|
||||||
|
operation: "encryption_init_error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads');
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await initializeEncryption();
|
||||||
|
|
||||||
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
port: PORT,
|
port: PORT,
|
||||||
@@ -290,6 +695,15 @@ app.listen(PORT, () => {
|
|||||||
"/health",
|
"/health",
|
||||||
"/version",
|
"/version",
|
||||||
"/releases/rss",
|
"/releases/rss",
|
||||||
|
"/encryption/status",
|
||||||
|
"/encryption/initialize",
|
||||||
|
"/encryption/migrate",
|
||||||
|
"/encryption/regenerate",
|
||||||
|
"/database/export",
|
||||||
|
"/database/import",
|
||||||
|
"/database/export/:exportPath/info",
|
||||||
|
"/database/backup",
|
||||||
|
"/database/restore",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as schema from "./schema.js";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const dbDir = path.resolve(dataDir);
|
const dbDir = path.resolve(dataDir);
|
||||||
@@ -15,12 +16,139 @@ if (!fs.existsSync(dbDir)) {
|
|||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database file encryption configuration
|
||||||
|
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== 'false';
|
||||||
const dbPath = path.join(dataDir, "db.sqlite");
|
const dbPath = path.join(dataDir, "db.sqlite");
|
||||||
|
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||||
|
|
||||||
|
// Initialize database with file encryption support
|
||||||
|
let actualDbPath = ':memory:'; // Always use memory database
|
||||||
|
let memoryDatabase: Database.Database;
|
||||||
|
let isNewDatabase = false;
|
||||||
|
|
||||||
|
if (enableFileEncryption) {
|
||||||
|
try {
|
||||||
|
// Check if encrypted database exists
|
||||||
|
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||||
|
databaseLogger.info('Found encrypted database file, loading into memory...', {
|
||||||
|
operation: 'db_memory_load',
|
||||||
|
encryptedPath: encryptedDbPath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate hardware compatibility
|
||||||
|
if (!DatabaseFileEncryption.validateHardwareCompatibility(encryptedDbPath)) {
|
||||||
|
databaseLogger.error('Hardware fingerprint mismatch for encrypted database', {
|
||||||
|
operation: 'db_decrypt_failed',
|
||||||
|
reason: 'hardware_mismatch'
|
||||||
|
});
|
||||||
|
throw new Error('Cannot decrypt database: hardware fingerprint mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt database content to memory buffer
|
||||||
|
const decryptedBuffer = DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||||
|
|
||||||
|
// Create in-memory database from decrypted buffer
|
||||||
|
memoryDatabase = new Database(decryptedBuffer);
|
||||||
|
|
||||||
|
databaseLogger.success('Existing database loaded into memory successfully', {
|
||||||
|
operation: 'db_memory_load_success',
|
||||||
|
bufferSize: decryptedBuffer.length,
|
||||||
|
inMemory: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No encrypted database exists - create new in-memory database
|
||||||
|
databaseLogger.info('No encrypted database found, creating new in-memory database', {
|
||||||
|
operation: 'db_memory_create_new'
|
||||||
|
});
|
||||||
|
|
||||||
|
memoryDatabase = new Database(':memory:');
|
||||||
|
isNewDatabase = true;
|
||||||
|
|
||||||
|
// Check if there's an old unencrypted database to migrate
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
databaseLogger.info('Found existing unencrypted database, will migrate to memory', {
|
||||||
|
operation: 'db_migrate_to_memory',
|
||||||
|
oldPath: dbPath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load old database and copy its content to memory database
|
||||||
|
const oldDb = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
|
// Get all table schemas and data from old database
|
||||||
|
const tables = oldDb.prepare(`
|
||||||
|
SELECT name, sql FROM sqlite_master
|
||||||
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
`).all() as { name: string; sql: string }[];
|
||||||
|
|
||||||
|
// Create tables in memory database
|
||||||
|
for (const table of tables) {
|
||||||
|
memoryDatabase.exec(table.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy data for each table
|
||||||
|
for (const table of tables) {
|
||||||
|
const rows = oldDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
|
const insertStmt = memoryDatabase.prepare(
|
||||||
|
`INSERT INTO ${table.name} (${columns.join(', ')}) VALUES (${placeholders})`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = columns.map(col => (row as any)[col]);
|
||||||
|
insertStmt.run(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldDb.close();
|
||||||
|
|
||||||
|
databaseLogger.success('Migrated existing database to memory', {
|
||||||
|
operation: 'db_migrate_to_memory_success'
|
||||||
|
});
|
||||||
|
isNewDatabase = false;
|
||||||
|
} else {
|
||||||
|
databaseLogger.success('Created new in-memory database', {
|
||||||
|
operation: 'db_memory_create_success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to initialize memory database', error, {
|
||||||
|
operation: 'db_memory_init_failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
// If file encryption is critical, fail fast
|
||||||
|
if (process.env.DB_FILE_ENCRYPTION_REQUIRED === 'true') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fallback in-memory database
|
||||||
|
databaseLogger.warn('Creating fallback in-memory database', {
|
||||||
|
operation: 'db_memory_fallback'
|
||||||
|
});
|
||||||
|
memoryDatabase = new Database(':memory:');
|
||||||
|
isNewDatabase = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File encryption disabled - still use memory for consistency
|
||||||
|
databaseLogger.info('File encryption disabled, using in-memory database', {
|
||||||
|
operation: 'db_memory_no_encryption'
|
||||||
|
});
|
||||||
|
memoryDatabase = new Database(':memory:');
|
||||||
|
isNewDatabase = true;
|
||||||
|
}
|
||||||
|
|
||||||
databaseLogger.info(`Initializing SQLite database`, {
|
databaseLogger.info(`Initializing SQLite database`, {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
path: dbPath,
|
path: actualDbPath,
|
||||||
|
encrypted: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||||
|
inMemory: true,
|
||||||
|
isNewDatabase
|
||||||
});
|
});
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
|
const sqlite = memoryDatabase;
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -250,6 +378,17 @@ const migrateSchema = () => {
|
|||||||
"INTEGER REFERENCES ssh_credentials(id)",
|
"INTEGER REFERENCES ssh_credentials(id)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"require_password",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
|
||||||
|
// SSH credentials table migrations for encryption support
|
||||||
|
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_recent", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||||
@@ -259,7 +398,7 @@ const migrateSchema = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeDatabase = async () => {
|
const initializeDatabase = async (): Promise<void> => {
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -292,15 +431,229 @@ const initializeDatabase = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeDatabase().catch((error) => {
|
// Function to save in-memory database to encrypted file
|
||||||
|
async function saveMemoryDatabaseToFile() {
|
||||||
|
if (!memoryDatabase || !enableFileEncryption) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Export in-memory database to buffer
|
||||||
|
const buffer = memoryDatabase.serialize();
|
||||||
|
|
||||||
|
// Encrypt and save to file
|
||||||
|
DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||||
|
|
||||||
|
databaseLogger.debug('In-memory database saved to encrypted file', {
|
||||||
|
operation: 'memory_db_save',
|
||||||
|
bufferSize: buffer.length,
|
||||||
|
encryptedPath: encryptedDbPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to save in-memory database', error, {
|
||||||
|
operation: 'memory_db_save_failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle post-initialization file encryption and cleanup
|
||||||
|
async function handlePostInitFileEncryption() {
|
||||||
|
if (!enableFileEncryption) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up any existing unencrypted database files
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
databaseLogger.warn('Found unencrypted database file, removing for security', {
|
||||||
|
operation: 'db_security_cleanup_existing',
|
||||||
|
removingPath: dbPath
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
databaseLogger.success('Unencrypted database file removed for security', {
|
||||||
|
operation: 'db_security_cleanup_complete',
|
||||||
|
removedPath: dbPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn('Could not remove unencrypted database file (may be locked)', {
|
||||||
|
operation: 'db_security_cleanup_deferred',
|
||||||
|
path: dbPath,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try again after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
databaseLogger.success('Delayed cleanup: unencrypted database file removed', {
|
||||||
|
operation: 'db_security_cleanup_delayed_success',
|
||||||
|
removedPath: dbPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (delayedError) {
|
||||||
|
databaseLogger.error('Failed to remove unencrypted database file even after delay', delayedError, {
|
||||||
|
operation: 'db_security_cleanup_delayed_failed',
|
||||||
|
path: dbPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always save the in-memory database (whether new or existing)
|
||||||
|
if (memoryDatabase) {
|
||||||
|
// Save immediately after initialization
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
|
||||||
|
// Set up periodic saves every 5 minutes
|
||||||
|
setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
databaseLogger.info('Periodic in-memory database saves configured', {
|
||||||
|
operation: 'memory_db_autosave_setup',
|
||||||
|
intervalMinutes: 5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to handle database file encryption/cleanup', error, {
|
||||||
|
operation: 'db_encrypt_cleanup_failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't fail the entire initialization for this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeDatabase()
|
||||||
|
.then(() => handlePostInitFileEncryption())
|
||||||
|
.catch((error) => {
|
||||||
databaseLogger.error("Failed to initialize database", error, {
|
databaseLogger.error("Failed to initialize database", error, {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
});
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
databaseLogger.success("Database connection established", {
|
databaseLogger.success("Database connection established", {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
path: dbPath,
|
path: actualDbPath,
|
||||||
|
hasEncryptedBackup: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup function for database and temporary files
|
||||||
|
async function cleanupDatabase() {
|
||||||
|
// Save in-memory database before closing
|
||||||
|
if (memoryDatabase) {
|
||||||
|
try {
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
databaseLogger.info('In-memory database saved before shutdown', {
|
||||||
|
operation: 'shutdown_save'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to save in-memory database before shutdown', error, {
|
||||||
|
operation: 'shutdown_save_failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
try {
|
||||||
|
if (sqlite) {
|
||||||
|
sqlite.close();
|
||||||
|
databaseLogger.debug('Database connection closed', {
|
||||||
|
operation: 'db_close'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn('Error closing database connection', {
|
||||||
|
operation: 'db_close_error',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
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 {
|
||||||
|
// Ignore individual file cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(tempDir);
|
||||||
|
databaseLogger.debug('Temp directory cleaned up', {
|
||||||
|
operation: 'temp_dir_cleanup'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore directory removal errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore temp directory cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register cleanup handlers
|
||||||
|
process.on('exit', () => {
|
||||||
|
// Synchronous cleanup only for exit event
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export database connection and file encryption utilities
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
export const sqliteInstance = sqlite; // Export underlying SQLite instance for schema queries
|
||||||
|
export { DatabaseFileEncryption };
|
||||||
|
export const databasePaths = {
|
||||||
|
main: actualDbPath,
|
||||||
|
encrypted: encryptedDbPath,
|
||||||
|
directory: dbDir,
|
||||||
|
inMemory: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memory database buffer function
|
||||||
|
function getMemoryDatabaseBuffer(): Buffer {
|
||||||
|
if (!memoryDatabase) {
|
||||||
|
throw new Error('Memory database not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Export in-memory database to buffer
|
||||||
|
const buffer = memoryDatabase.serialize();
|
||||||
|
|
||||||
|
databaseLogger.debug('Memory database serialized to buffer', {
|
||||||
|
operation: 'memory_db_serialize',
|
||||||
|
bufferSize: buffer.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to serialize memory database to buffer', error, {
|
||||||
|
operation: 'memory_db_serialize_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export save function for manual saves and buffer access
|
||||||
|
export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer };
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
|
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
|
requirePassword: integer("require_password", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
key: text("key", { length: 8192 }),
|
key: text("key", { length: 8192 }),
|
||||||
keyPassword: text("key_password"),
|
keyPassword: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
@@ -137,9 +140,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 16384 }),
|
key: text("key", { length: 16384 }), // backward compatibility
|
||||||
|
privateKey: text("private_key", { length: 16384 }),
|
||||||
|
publicKey: text("public_key", { length: 4096 }),
|
||||||
keyPassword: text("key_password"),
|
keyPassword: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
|
detectedKeyType: text("detected_key_type"),
|
||||||
usageCount: integer("usage_count").notNull().default(0),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
lastUsed: text("last_used"),
|
lastUsed: text("last_used"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
|
|||||||
@@ -5,6 +5,57 @@ import { eq, and, desc, sql } from "drizzle-orm";
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
|
||||||
|
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import ssh2Pkg from "ssh2";
|
||||||
|
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
||||||
|
|
||||||
|
// Direct SSH key generation with ssh2 - the right way
|
||||||
|
function generateSSHKeyPair(keyType: string, keySize?: number, passphrase?: string): { success: boolean; privateKey?: string; publicKey?: string; error?: string } {
|
||||||
|
console.log('Generating SSH key pair with ssh2:', keyType);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert our keyType to ssh2 format
|
||||||
|
let ssh2Type = keyType;
|
||||||
|
const options: any = {};
|
||||||
|
|
||||||
|
if (keyType === 'ssh-rsa') {
|
||||||
|
ssh2Type = 'rsa';
|
||||||
|
options.bits = keySize || 2048;
|
||||||
|
} else if (keyType === 'ssh-ed25519') {
|
||||||
|
ssh2Type = 'ed25519';
|
||||||
|
} else if (keyType === 'ecdsa-sha2-nistp256') {
|
||||||
|
ssh2Type = 'ecdsa';
|
||||||
|
options.bits = 256; // ECDSA P-256 uses 256 bits
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add passphrase protection if provided
|
||||||
|
if (passphrase && passphrase.trim()) {
|
||||||
|
options.passphrase = passphrase;
|
||||||
|
options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh2's native key generation
|
||||||
|
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||||
|
|
||||||
|
console.log('SSH key pair generated successfully!');
|
||||||
|
console.log('Private key length:', keyPair.private.length);
|
||||||
|
console.log('Public key preview:', keyPair.public.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
privateKey: keyPair.private,
|
||||||
|
publicKey: keyPair.public
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key generation failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'SSH key generation failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -109,6 +160,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const plainKeyPassword =
|
const plainKeyPassword =
|
||||||
authType === "key" && keyPassword ? keyPassword : null;
|
authType === "key" && keyPassword ? keyPassword : null;
|
||||||
|
|
||||||
|
let keyInfo = null;
|
||||||
|
if (authType === "key" && plainKey) {
|
||||||
|
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||||
|
if (!keyInfo.success) {
|
||||||
|
authLogger.warn("SSH key parsing failed", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
error: keyInfo.error,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const credentialData = {
|
const credentialData = {
|
||||||
userId,
|
userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -118,18 +185,21 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
authType,
|
authType,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: plainPassword,
|
password: plainPassword,
|
||||||
key: plainKey,
|
key: plainKey, // backward compatibility
|
||||||
|
privateKey: keyInfo?.privateKey || plainKey,
|
||||||
|
publicKey: keyInfo?.publicKey || null,
|
||||||
keyPassword: plainKeyPassword,
|
keyPassword: plainKeyPassword,
|
||||||
keyType: keyType || null,
|
keyType: keyType || null,
|
||||||
|
detectedKeyType: keyInfo?.keyType || null,
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
lastUsed: null,
|
lastUsed: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await db
|
const created = await EncryptedDBOperations.insert(
|
||||||
.insert(sshCredentials)
|
sshCredentials,
|
||||||
.values(credentialData)
|
'ssh_credentials',
|
||||||
.returning();
|
credentialData
|
||||||
const created = result[0];
|
) as typeof credentialData & { id: number };
|
||||||
|
|
||||||
authLogger.success(
|
authLogger.success(
|
||||||
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
||||||
@@ -169,11 +239,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(eq(sshCredentials.userId, userId)).orderBy(desc(sshCredentials.updatedAt)),
|
||||||
.from(sshCredentials)
|
'ssh_credentials'
|
||||||
.where(eq(sshCredentials.userId, userId))
|
);
|
||||||
.orderBy(desc(sshCredentials.updatedAt));
|
|
||||||
|
|
||||||
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -227,14 +296,12 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(and(
|
||||||
.from(sshCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sshCredentials.id, parseInt(id)),
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
)),
|
||||||
|
'ssh_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
@@ -248,7 +315,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
(output as any).password = credential.password;
|
(output as any).password = credential.password;
|
||||||
}
|
}
|
||||||
if (credential.key) {
|
if (credential.key) {
|
||||||
(output as any).key = credential.key;
|
(output as any).key = credential.key; // backward compatibility
|
||||||
|
}
|
||||||
|
if (credential.privateKey) {
|
||||||
|
(output as any).privateKey = credential.privateKey;
|
||||||
|
}
|
||||||
|
if (credential.publicKey) {
|
||||||
|
(output as any).publicKey = credential.publicKey;
|
||||||
}
|
}
|
||||||
if (credential.keyPassword) {
|
if (credential.keyPassword) {
|
||||||
(output as any).keyPassword = credential.keyPassword;
|
(output as any).keyPassword = credential.keyPassword;
|
||||||
@@ -314,35 +387,54 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
updateFields.password = updateData.password || null;
|
updateFields.password = updateData.password || null;
|
||||||
}
|
}
|
||||||
if (updateData.key !== undefined) {
|
if (updateData.key !== undefined) {
|
||||||
updateFields.key = updateData.key || null;
|
updateFields.key = updateData.key || null; // backward compatibility
|
||||||
|
|
||||||
|
// Parse SSH key if provided
|
||||||
|
if (updateData.key && existing[0].authType === "key") {
|
||||||
|
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||||
|
if (!keyInfo.success) {
|
||||||
|
authLogger.warn("SSH key parsing failed during update", {
|
||||||
|
operation: "credential_update",
|
||||||
|
userId,
|
||||||
|
credentialId: parseInt(id),
|
||||||
|
error: keyInfo.error,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateFields.privateKey = keyInfo.privateKey;
|
||||||
|
updateFields.publicKey = keyInfo.publicKey;
|
||||||
|
updateFields.detectedKeyType = keyInfo.keyType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updateData.keyPassword !== undefined) {
|
if (updateData.keyPassword !== undefined) {
|
||||||
updateFields.keyPassword = updateData.keyPassword || null;
|
updateFields.keyPassword = updateData.keyPassword || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateFields).length === 0) {
|
if (Object.keys(updateFields).length === 0) {
|
||||||
const existing = await db
|
const existing = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))),
|
||||||
.from(sshCredentials)
|
'ssh_credentials'
|
||||||
.where(eq(sshCredentials.id, parseInt(id)));
|
);
|
||||||
|
|
||||||
return res.json(formatCredentialOutput(existing[0]));
|
return res.json(formatCredentialOutput(existing[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await EncryptedDBOperations.update(
|
||||||
.update(sshCredentials)
|
sshCredentials,
|
||||||
.set(updateFields)
|
'ssh_credentials',
|
||||||
.where(
|
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, parseInt(id)),
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
|
updateFields
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await db
|
const updated = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))),
|
||||||
.from(sshCredentials)
|
'ssh_credentials'
|
||||||
.where(eq(sshCredentials.id, parseInt(id)));
|
);
|
||||||
|
|
||||||
const credential = updated[0];
|
const credential = updated[0];
|
||||||
authLogger.success(
|
authLogger.success(
|
||||||
@@ -584,7 +676,9 @@ function formatCredentialOutput(credential: any): any {
|
|||||||
: [],
|
: [],
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
username: credential.username,
|
username: credential.username,
|
||||||
|
publicKey: credential.publicKey,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
|
detectedKeyType: credential.detectedKeyType,
|
||||||
usageCount: credential.usageCount || 0,
|
usageCount: credential.usageCount || 0,
|
||||||
lastUsed: credential.lastUsed,
|
lastUsed: credential.lastUsed,
|
||||||
createdAt: credential.createdAt,
|
createdAt: credential.createdAt,
|
||||||
@@ -661,4 +755,642 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Detect SSH key type endpoint
|
||||||
|
// POST /credentials/detect-key-type
|
||||||
|
router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Key Detection API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Private key type:", typeof privateKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling parseSSHKey...");
|
||||||
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
|
console.log("parseSSHKey result:", keyInfo);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: keyInfo.success,
|
||||||
|
keyType: keyInfo.keyType,
|
||||||
|
detectedKeyType: keyInfo.keyType,
|
||||||
|
hasPublicKey: !!keyInfo.publicKey,
|
||||||
|
error: keyInfo.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in detect-key-type endpoint:", error);
|
||||||
|
authLogger.error("Failed to detect key type", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to detect key type"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect SSH public key type endpoint
|
||||||
|
// POST /credentials/detect-public-key-type
|
||||||
|
router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { publicKey } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Public Key Detection API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Public key provided:", !!publicKey);
|
||||||
|
console.log("Public key type:", typeof publicKey);
|
||||||
|
|
||||||
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
|
console.log("Invalid public key provided");
|
||||||
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling parsePublicKey...");
|
||||||
|
const keyInfo = parsePublicKey(publicKey);
|
||||||
|
console.log("parsePublicKey result:", keyInfo);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: keyInfo.success,
|
||||||
|
keyType: keyInfo.keyType,
|
||||||
|
detectedKeyType: keyInfo.keyType,
|
||||||
|
error: keyInfo.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in detect-public-key-type endpoint:", error);
|
||||||
|
authLogger.error("Failed to detect public key type", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to detect public key type"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate SSH key pair endpoint
|
||||||
|
// POST /credentials/validate-key-pair
|
||||||
|
router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, publicKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Key Pair Validation API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Public key provided:", !!publicKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
|
console.log("Invalid public key provided");
|
||||||
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling validateKeyPair...");
|
||||||
|
const validationResult = validateKeyPair(privateKey, publicKey, keyPassword);
|
||||||
|
console.log("validateKeyPair result:", validationResult);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
isValid: validationResult.isValid,
|
||||||
|
privateKeyType: validationResult.privateKeyType,
|
||||||
|
publicKeyType: validationResult.publicKeyType,
|
||||||
|
generatedPublicKey: validationResult.generatedPublicKey,
|
||||||
|
error: validationResult.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in validate-key-pair endpoint:", error);
|
||||||
|
authLogger.error("Failed to validate key pair", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to validate key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate new SSH key pair endpoint
|
||||||
|
// POST /credentials/generate-key-pair
|
||||||
|
router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { keyType = 'ssh-ed25519', keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Generate Key Pair API Called ===");
|
||||||
|
console.log("Key type:", keyType);
|
||||||
|
console.log("Key size:", keySize);
|
||||||
|
console.log("Has passphrase:", !!passphrase);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate SSH keys directly with ssh2
|
||||||
|
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||||
|
|
||||||
|
if (result.success && result.privateKey && result.publicKey) {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
privateKey: result.privateKey,
|
||||||
|
publicKey: result.publicKey,
|
||||||
|
keyType: keyType,
|
||||||
|
format: 'ssh',
|
||||||
|
algorithm: keyType,
|
||||||
|
keySize: keyType === 'ssh-rsa' ? keySize : undefined,
|
||||||
|
curve: keyType === 'ecdsa-sha2-nistp256' ? 'nistp256' : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("SSH key pair generated successfully:", keyType);
|
||||||
|
res.json(response);
|
||||||
|
} else {
|
||||||
|
console.error("SSH key generation failed:", result.error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || "Failed to generate SSH key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in generate-key-pair endpoint:", error);
|
||||||
|
authLogger.error("Failed to generate key pair", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to generate key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate public key from private key endpoint
|
||||||
|
// POST /credentials/generate-public-key
|
||||||
|
router.post("/generate-public-key", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Generate Public Key API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Private key type:", typeof privateKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Using Node.js crypto to generate public key from private key...");
|
||||||
|
console.log("Private key length:", privateKey.length);
|
||||||
|
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
|
||||||
|
|
||||||
|
// First try to create private key object from the input
|
||||||
|
let privateKeyObj;
|
||||||
|
let parseAttempts = [];
|
||||||
|
|
||||||
|
// Attempt 1: Direct parsing with passphrase
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
passphrase: keyPassword
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed with passphrase method");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 2: Direct parsing without passphrase
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||||
|
console.log("Successfully parsed without passphrase");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 3: Try with explicit format specification
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'pkcs8'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as PKCS#8");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 4: Try as PKCS#1 RSA
|
||||||
|
if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'pkcs1'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as PKCS#1 RSA");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 5: Try as SEC1 EC
|
||||||
|
if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'sec1'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as SEC1 EC");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final attempt: Try using ssh2 as fallback
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
console.log("Attempting fallback to parseSSHKey function...");
|
||||||
|
This file contains numerous 
This file contains numerous `console.log` statements used for debugging. While useful during development, they should be removed or replaced with a proper logging utility (like the `authLogger` used elsewhere in this file) before merging to avoid leaking sensitive information and cluttering production logs.
|
|||||||
|
try {
|
||||||
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
|
console.log("parseSSHKey fallback result:", keyInfo);
|
||||||
|
|
||||||
|
if (keyInfo.success && keyInfo.publicKey) {
|
||||||
|
// Ensure SSH2 fallback also returns proper string
|
||||||
|
const publicKeyString = String(keyInfo.publicKey);
|
||||||
|
console.log("SSH2 fallback public key type:", typeof publicKeyString);
|
||||||
|
console.log("SSH2 fallback public key length:", publicKeyString.length);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
publicKey: publicKeyString,
|
||||||
|
keyType: keyInfo.keyType
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
parseAttempts.push(`SSH2 fallback: ${keyInfo.error || 'No public key generated'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
console.error("All parsing attempts failed:", parseAttempts);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Unable to parse private key. Tried multiple formats.",
|
||||||
|
details: parseAttempts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public key from private key
|
||||||
|
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
||||||
|
const publicKeyPem = publicKeyObj.export({
|
||||||
|
type: 'spki',
|
||||||
|
format: 'pem'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Check what we're actually generating
|
||||||
|
console.log("Generated public key type:", typeof publicKeyPem);
|
||||||
|
console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem));
|
||||||
|
|
||||||
|
// Ensure publicKeyPem is a string
|
||||||
|
const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8');
|
||||||
|
|
||||||
|
console.log("Public key string length:", publicKeyString.length);
|
||||||
|
console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100));
|
||||||
|
console.log("Public key is string:", typeof publicKeyString === 'string');
|
||||||
|
console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----'));
|
||||||
|
|
||||||
|
// Detect key type from the private key object
|
||||||
|
let keyType = 'unknown';
|
||||||
|
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||||
|
|
||||||
|
if (asymmetricKeyType === 'rsa') {
|
||||||
|
keyType = 'ssh-rsa';
|
||||||
|
} else if (asymmetricKeyType === 'ed25519') {
|
||||||
|
keyType = 'ssh-ed25519';
|
||||||
|
} else if (asymmetricKeyType === 'ec') {
|
||||||
|
// For EC keys, we need to check the curve
|
||||||
|
keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh2 to generate SSH format public key
|
||||||
|
let finalPublicKey = publicKeyString; // PEM fallback
|
||||||
|
let formatType = 'pem';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
|
||||||
|
if (!(ssh2PrivateKey instanceof Error)) {
|
||||||
|
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
|
||||||
|
const base64Data = publicKeyBuffer.toString('base64');
|
||||||
|
finalPublicKey = `${keyType} ${base64Data}`;
|
||||||
|
formatType = 'ssh';
|
||||||
|
console.log("SSH format public key generated!");
|
||||||
|
} else {
|
||||||
|
console.warn("ssh2 parsing failed, using PEM format");
|
||||||
|
}
|
||||||
|
} catch (sshError) {
|
||||||
|
console.warn("ssh2 failed, using PEM format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
publicKey: finalPublicKey,
|
||||||
|
keyType: keyType,
|
||||||
|
format: formatType
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Final response publicKey type:", typeof response.publicKey);
|
||||||
|
console.log("Final response publicKey format:", response.format);
|
||||||
|
console.log("Final response publicKey length:", response.publicKey.length);
|
||||||
|
console.log("Public key generated successfully using crypto module:", keyType);
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in generate-public-key endpoint:", error);
|
||||||
|
authLogger.error("Failed to generate public key", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to generate public key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSH Key Deployment Function
|
||||||
|
async function deploySSHKeyToHost(
|
||||||
|
hostConfig: any,
|
||||||
|
publicKey: string,
|
||||||
|
credentialData: any
|
||||||
|
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const conn = new Client();
|
||||||
|
let connectionTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// Connection timeout
|
||||||
|
connectionTimeout = setTimeout(() => {
|
||||||
|
conn.destroy();
|
||||||
|
resolve({ success: false, error: "Connection timeout" });
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
conn.on('ready', async () => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create ~/.ssh directory if it doesn't exist
|
||||||
|
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||||
|
conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => {
|
||||||
|
if (err) return rejectCmd(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolveCmd();
|
||||||
|
} else {
|
||||||
|
rejectCmd(new Error(`mkdir command failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Check if public key already exists
|
||||||
|
const keyExists = await new Promise<boolean>((resolveCheck, rejectCheck) => {
|
||||||
|
const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
|
||||||
|
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => {
|
||||||
|
if (err) return rejectCheck(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
resolveCheck(code === 0); // code 0 means key found
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keyExists) {
|
||||||
|
conn.end();
|
||||||
|
resolve({ success: true, message: "SSH key already deployed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Add public key to authorized_keys
|
||||||
|
await new Promise<void>((resolveAdd, rejectAdd) => {
|
||||||
|
const escapedKey = publicKey.replace(/'/g, "'\\''");
|
||||||
|
conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => {
|
||||||
|
if (err) return rejectAdd(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolveAdd();
|
||||||
|
} else {
|
||||||
|
rejectAdd(new Error(`Key deployment failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Verify deployment
|
||||||
|
const verifySuccess = await new Promise<boolean>((resolveVerify, rejectVerify) => {
|
||||||
|
const keyPattern = publicKey.split(' ')[1];
|
||||||
|
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => {
|
||||||
|
if (err) return rejectVerify(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
resolveVerify(code === 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
if (verifySuccess) {
|
||||||
|
resolve({ success: true, message: "SSH key deployed successfully" });
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: "Key deployment verification failed" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
conn.end();
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Deployment failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
resolve({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to the target host
|
||||||
|
try {
|
||||||
|
const connectionConfig: any = {
|
||||||
|
host: hostConfig.ip,
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
username: hostConfig.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hostConfig.authType === 'password' && hostConfig.password) {
|
||||||
|
connectionConfig.password = hostConfig.password;
|
||||||
|
} else if (hostConfig.authType === 'key' && hostConfig.privateKey) {
|
||||||
|
connectionConfig.privateKey = hostConfig.privateKey;
|
||||||
|
if (hostConfig.keyPassword) {
|
||||||
|
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: "Invalid authentication configuration" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.connect(connectionConfig);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Connection failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy SSH Key to Host endpoint
|
||||||
|
// POST /credentials/:id/deploy-to-host
|
||||||
|
router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const credentialId = parseInt(req.params.id);
|
||||||
|
const { targetHostId } = req.body;
|
||||||
|
|
||||||
|
|
||||||
|
if (!credentialId || !targetHostId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Credential ID and target host ID are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get credential details
|
||||||
|
const credential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, credentialId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!credential || credential.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Credential not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const credData = credential[0];
|
||||||
|
|
||||||
|
// Only support key-based credentials for deployment
|
||||||
|
if (credData.authType !== 'key') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Only SSH key-based credentials can be deployed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credData.publicKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Public key is required for deployment"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target host details
|
||||||
|
const targetHost = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.id, targetHostId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetHost || targetHost.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Target host not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostData = targetHost[0];
|
||||||
|
|
||||||
|
// Prepare host configuration for connection
|
||||||
|
let hostConfig = {
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username: hostData.username,
|
||||||
|
authType: hostData.authType,
|
||||||
|
password: hostData.password,
|
||||||
|
privateKey: hostData.key,
|
||||||
|
keyPassword: hostData.keyPassword
|
||||||
|
};
|
||||||
|
|
||||||
|
// If host uses credential authentication, resolve the credential
|
||||||
|
if (hostData.authType === 'credential' && hostData.credentialId) {
|
||||||
|
const hostCredential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, hostData.credentialId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (hostCredential && hostCredential.length > 0) {
|
||||||
|
const cred = hostCredential[0];
|
||||||
|
|
||||||
|
// Update hostConfig with credential data
|
||||||
|
hostConfig.authType = cred.authType;
|
||||||
|
hostConfig.username = cred.username; // Use credential's username
|
||||||
|
|
||||||
|
if (cred.authType === 'password') {
|
||||||
|
hostConfig.password = cred.password;
|
||||||
|
} else if (cred.authType === 'key') {
|
||||||
|
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
|
||||||
|
hostConfig.keyPassword = cred.keyPassword;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Host credential not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy the SSH key
|
||||||
|
const deployResult = await deploySSHKeyToHost(
|
||||||
|
hostConfig,
|
||||||
|
credData.publicKey,
|
||||||
|
credData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deployResult.success) {
|
||||||
|
// Log successful deployment
|
||||||
|
authLogger.info(`SSH key deployed successfully`, {
|
||||||
|
credentialId,
|
||||||
|
targetHostId,
|
||||||
|
operation: "deploy_ssh_key"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: deployResult.message || "SSH key deployed successfully"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
authLogger.error(`SSH key deployment failed`, {
|
||||||
|
credentialId,
|
||||||
|
targetHostId,
|
||||||
|
error: deployResult.error,
|
||||||
|
operation: "deploy_ssh_key"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: deployResult.error || "Deployment failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to deploy SSH key", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to deploy SSH key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { Request, Response, NextFunction } from "express";
|
|||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -62,7 +63,10 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
return res.status(403).json({ error: "Forbidden" });
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db.select().from(sshData);
|
const data = await EncryptedDBOperations.select(
|
||||||
|
db.select().from(sshData),
|
||||||
|
'ssh_data'
|
||||||
|
);
|
||||||
const result = data.map((row: any) => {
|
const result = data.map((row: any) => {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -73,6 +77,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!row.pin,
|
pin: !!row.pin,
|
||||||
|
requirePassword: !!row.requirePassword,
|
||||||
enableTerminal: !!row.enableTerminal,
|
enableTerminal: !!row.enableTerminal,
|
||||||
enableTunnel: !!row.enableTunnel,
|
enableTunnel: !!row.enableTunnel,
|
||||||
tunnelConnections: row.tunnelConnections
|
tunnelConnections: row.tunnelConnections
|
||||||
@@ -133,6 +138,7 @@ router.post(
|
|||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
requirePassword,
|
||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
@@ -184,6 +190,7 @@ router.post(
|
|||||||
|
|
||||||
if (effectiveAuthType === "password") {
|
if (effectiveAuthType === "password") {
|
||||||
sshDataObj.password = password || null;
|
sshDataObj.password = password || null;
|
||||||
|
sshDataObj.requirePassword = requirePassword !== false ? 1 : 0;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
@@ -192,12 +199,20 @@ router.post(
|
|||||||
sshDataObj.keyPassword = keyPassword || null;
|
sshDataObj.keyPassword = keyPassword || null;
|
||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||||
|
} else {
|
||||||
|
// For credential auth
|
||||||
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await db.insert(sshData).values(sshDataObj).returning();
|
const result = await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!result) {
|
||||||
sshLogger.warn("No host returned after creation", {
|
sshLogger.warn("No host returned after creation", {
|
||||||
operation: "host_create",
|
operation: "host_create",
|
||||||
userId,
|
userId,
|
||||||
@@ -208,7 +223,7 @@ router.post(
|
|||||||
return res.status(500).json({ error: "Failed to create host" });
|
return res.status(500).json({ error: "Failed to create host" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdHost = result[0];
|
const createdHost = result;
|
||||||
const baseHost = {
|
const baseHost = {
|
||||||
...createdHost,
|
...createdHost,
|
||||||
tags:
|
tags:
|
||||||
@@ -218,6 +233,7 @@ router.post(
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!createdHost.pin,
|
pin: !!createdHost.pin,
|
||||||
|
requirePassword: !!createdHost.requirePassword,
|
||||||
enableTerminal: !!createdHost.enableTerminal,
|
enableTerminal: !!createdHost.enableTerminal,
|
||||||
enableTunnel: !!createdHost.enableTunnel,
|
enableTunnel: !!createdHost.enableTunnel,
|
||||||
tunnelConnections: createdHost.tunnelConnections
|
tunnelConnections: createdHost.tunnelConnections
|
||||||
@@ -304,6 +320,7 @@ router.put(
|
|||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
requirePassword,
|
||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
@@ -358,6 +375,7 @@ router.put(
|
|||||||
if (password) {
|
if (password) {
|
||||||
sshDataObj.password = password;
|
sshDataObj.password = password;
|
||||||
}
|
}
|
||||||
|
sshDataObj.requirePassword = requirePassword !== false ? 1 : 0;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
@@ -372,18 +390,28 @@ router.put(
|
|||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
}
|
}
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||||
|
} else {
|
||||||
|
// For credential auth
|
||||||
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await EncryptedDBOperations.update(
|
||||||
.update(sshData)
|
sshData,
|
||||||
.set(sshDataObj)
|
'ssh_data',
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||||
|
sshDataObj
|
||||||
|
);
|
||||||
|
|
||||||
const updatedHosts = await db
|
const updatedHosts = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))),
|
||||||
.from(sshData)
|
'ssh_data'
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
);
|
||||||
|
|
||||||
if (updatedHosts.length === 0) {
|
if (updatedHosts.length === 0) {
|
||||||
sshLogger.warn("Updated host not found after update", {
|
sshLogger.warn("Updated host not found after update", {
|
||||||
@@ -404,6 +432,7 @@ router.put(
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!updatedHost.pin,
|
pin: !!updatedHost.pin,
|
||||||
|
requirePassword: !!updatedHost.requirePassword,
|
||||||
enableTerminal: !!updatedHost.enableTerminal,
|
enableTerminal: !!updatedHost.enableTerminal,
|
||||||
enableTunnel: !!updatedHost.enableTunnel,
|
enableTunnel: !!updatedHost.enableTunnel,
|
||||||
tunnelConnections: updatedHost.tunnelConnections
|
tunnelConnections: updatedHost.tunnelConnections
|
||||||
@@ -455,10 +484,10 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: "Invalid userId" });
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db
|
const data = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||||
.from(sshData)
|
'ssh_data'
|
||||||
.where(eq(sshData.userId, userId));
|
);
|
||||||
|
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
data.map(async (row: any) => {
|
data.map(async (row: any) => {
|
||||||
@@ -471,6 +500,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!row.pin,
|
pin: !!row.pin,
|
||||||
|
requirePassword: !!row.requirePassword,
|
||||||
enableTerminal: !!row.enableTerminal,
|
enableTerminal: !!row.enableTerminal,
|
||||||
enableTunnel: !!row.enableTunnel,
|
enableTunnel: !!row.enableTunnel,
|
||||||
tunnelConnections: row.tunnelConnections
|
tunnelConnections: row.tunnelConnections
|
||||||
@@ -1074,14 +1104,15 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedHosts = await db
|
const updatedHosts = await EncryptedDBOperations.update(
|
||||||
.update(sshData)
|
sshData,
|
||||||
.set({
|
'ssh_data',
|
||||||
|
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
|
||||||
|
{
|
||||||
folder: newName,
|
folder: newName,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
}
|
||||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName)))
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
const updatedCredentials = await db
|
const updatedCredentials = await db
|
||||||
.update(sshCredentials)
|
.update(sshCredentials)
|
||||||
@@ -1221,7 +1252,7 @@ router.post(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(sshData).values(sshDataObj);
|
await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
|
||||||
results.success++;
|
results.success++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
|
|||||||
+716
-25
@@ -5,6 +5,26 @@ import { db } from "../database/db/index.js";
|
|||||||
import { sshCredentials } from "../database/db/schema.js";
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { fileLogger } from "../utils/logger.js";
|
import { fileLogger } from "../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||||
|
|
||||||
|
// 可执行文件检测工具函数
|
||||||
|
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||||
|
// 检查执行权限位 (user, group, other)
|
||||||
|
const hasExecutePermission = permissions[3] === 'x' || permissions[6] === 'x' || permissions[9] === 'x';
|
||||||
|
|
||||||
|
// 常见的脚本文件扩展名
|
||||||
|
const scriptExtensions = ['.sh', '.py', '.pl', '.rb', '.js', '.php', '.bash', '.zsh', '.fish'];
|
||||||
|
const hasScriptExtension = scriptExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
// 常见的编译可执行文件(无扩展名或特定扩展名)
|
||||||
|
const executableExtensions = ['.bin', '.exe', '.out'];
|
||||||
|
const hasExecutableExtension = executableExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
// 无扩展名且有执行权限的文件通常是可执行文件
|
||||||
|
const hasNoExtension = !fileName.includes('.') && hasExecutePermission;
|
||||||
|
|
||||||
|
return hasExecutePermission && (hasScriptExtension || hasExecutableExtension || hasNoExtension);
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -85,56 +105,47 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||||
if (credentialId && hostId && userId) {
|
if (credentialId && hostId && userId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(
|
||||||
.from(sshCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
'ssh_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
fileLogger.warn("No credentials found in database for file manager", {
|
fileLogger.warn(`No credentials found for host ${hostId}`, {
|
||||||
operation: "file_connect",
|
operation: "ssh_credentials",
|
||||||
sessionId,
|
|
||||||
hostId,
|
hostId,
|
||||||
credentialId,
|
credentialId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fileLogger.warn(
|
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
|
||||||
"Failed to resolve credentials from database for file manager",
|
operation: "ssh_credentials",
|
||||||
{
|
|
||||||
operation: "file_connect",
|
|
||||||
sessionId,
|
|
||||||
hostId,
|
hostId,
|
||||||
credentialId,
|
credentialId,
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (credentialId && hostId) {
|
} else if (credentialId && hostId) {
|
||||||
fileLogger.warn(
|
fileLogger.warn("Missing userId for credential resolution in file manager", {
|
||||||
"Missing userId for credential resolution in file manager",
|
operation: "ssh_credentials",
|
||||||
{
|
|
||||||
operation: "file_connect",
|
|
||||||
sessionId,
|
|
||||||
hostId,
|
hostId,
|
||||||
credentialId,
|
credentialId,
|
||||||
hasUserId: !!userId,
|
hasUserId: !!userId,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: any = {
|
const config: any = {
|
||||||
@@ -311,20 +322,116 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
const parts = line.split(/\s+/);
|
const parts = line.split(/\s+/);
|
||||||
if (parts.length >= 9) {
|
if (parts.length >= 9) {
|
||||||
const permissions = parts[0];
|
const permissions = parts[0];
|
||||||
const name = parts.slice(8).join(" ");
|
const linkCount = parts[1];
|
||||||
|
const owner = parts[2];
|
||||||
|
const group = parts[3];
|
||||||
|
const size = parseInt(parts[4], 10);
|
||||||
|
|
||||||
|
// 日期可能占夨3个部分(月 日 时间)或者是(月 日 年)
|
||||||
|
let dateStr = "";
|
||||||
|
let nameStartIndex = 8;
|
||||||
|
|
||||||
|
if (parts[5] && parts[6] && parts[7]) {
|
||||||
|
// 常规格式: 月 日 时间/年
|
||||||
|
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parts.slice(nameStartIndex).join(" ");
|
||||||
const isDirectory = permissions.startsWith("d");
|
const isDirectory = permissions.startsWith("d");
|
||||||
const isLink = permissions.startsWith("l");
|
const isLink = permissions.startsWith("l");
|
||||||
|
|
||||||
if (name === "." || name === "..") continue;
|
if (name === "." || name === "..") continue;
|
||||||
|
|
||||||
|
// 解析符号链接目标
|
||||||
|
let actualName = name;
|
||||||
|
let linkTarget = undefined;
|
||||||
|
if (isLink && name.includes(" -> ")) {
|
||||||
|
const linkParts = name.split(" -> ");
|
||||||
|
actualName = linkParts[0];
|
||||||
|
linkTarget = linkParts[1];
|
||||||
|
}
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
name,
|
name: actualName,
|
||||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||||
|
size: isDirectory ? undefined : size, // 目录不显示大小
|
||||||
|
modified: dateStr,
|
||||||
|
permissions,
|
||||||
|
owner,
|
||||||
|
group,
|
||||||
|
linkTarget, // 符号链接的目标
|
||||||
|
path: `${sshPath.endsWith('/') ? sshPath : sshPath + '/'}${actualName}`, // 添加完整路径
|
||||||
|
executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false // 检测可执行文件
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(files);
|
res.json({ files, path: sshPath });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
const linkPath = decodeURIComponent(req.query.path as string);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: "Session ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshConn?.isConnected) {
|
||||||
|
return res.status(400).json({ error: "SSH connection not established" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkPath) {
|
||||||
|
return res.status(400).json({ error: "Link path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
|
||||||
|
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
|
||||||
|
|
||||||
|
sshConn.client.exec(command, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH identifySymlink error:", err);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
let errorData = "";
|
||||||
|
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
errorData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
fileLogger.error(
|
||||||
|
`SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||||
|
);
|
||||||
|
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fileType, target] = data.trim().split("\n");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
path: linkPath,
|
||||||
|
target: target,
|
||||||
|
type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH identifySymlink stream error:", streamErr);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -348,7 +455,59 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
// First check file size to prevent loading huge files
|
||||||
|
const MAX_READ_SIZE = 10 * 1024 * 1024; // 10MB - same as frontend limit
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
|
// Get file size first
|
||||||
|
sshConn.client.exec(`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, (sizeErr, sizeStream) => {
|
||||||
|
if (sizeErr) {
|
||||||
|
fileLogger.error("SSH file size check error:", sizeErr);
|
||||||
|
return res.status(500).json({ error: sizeErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizeData = "";
|
||||||
|
let sizeErrorData = "";
|
||||||
|
|
||||||
|
sizeStream.on("data", (chunk: Buffer) => {
|
||||||
|
sizeData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sizeStream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
sizeErrorData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sizeStream.on("close", (sizeCode) => {
|
||||||
|
if (sizeCode !== 0) {
|
||||||
|
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
||||||
|
return res.status(500).json({ error: `Cannot check file size: ${sizeErrorData}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSize = parseInt(sizeData.trim(), 10);
|
||||||
|
|
||||||
|
if (isNaN(fileSize)) {
|
||||||
|
fileLogger.error("Invalid file size response:", sizeData);
|
||||||
|
return res.status(500).json({ error: "Cannot determine file size" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is too large
|
||||||
|
if (fileSize > MAX_READ_SIZE) {
|
||||||
|
fileLogger.warn("File too large for reading", {
|
||||||
|
operation: "file_read",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
fileSize,
|
||||||
|
maxSize: MAX_READ_SIZE,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`,
|
||||||
|
fileSize,
|
||||||
|
maxSize: MAX_READ_SIZE,
|
||||||
|
tooLarge: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// File size is acceptable, proceed with reading
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("SSH readFile error:", err);
|
fileLogger.error("SSH readFile error:", err);
|
||||||
@@ -377,6 +536,8 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
res.json({ content: data, path: filePath });
|
res.json({ content: data, path: filePath });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||||
@@ -1269,6 +1430,434 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New API for moving files/folders across directories (for cut operation)
|
||||||
|
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||||
|
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: "Session ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshConn?.isConnected) {
|
||||||
|
return res.status(400).json({ error: "SSH connection not established" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldPath || !newPath) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Old path and new path are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||||
|
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
|
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
|
sshConn.client.exec(moveCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH moveItem error:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputData = "";
|
||||||
|
let errorData = "";
|
||||||
|
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
outputData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
errorData += chunk.toString();
|
||||||
|
|
||||||
|
if (chunk.toString().includes("Permission denied")) {
|
||||||
|
fileLogger.error(`Permission denied moving: ${oldPath}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||||
|
toast: {
|
||||||
|
type: "error",
|
||||||
|
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
if (outputData.includes("SUCCESS")) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.json({
|
||||||
|
message: "Item moved successfully",
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
fileLogger.error(
|
||||||
|
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||||
|
);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: `Command failed: ${errorData}`,
|
||||||
|
toast: { type: "error", message: `Move failed: ${errorData}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.json({
|
||||||
|
message: "Item moved successfully",
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH moveItem stream error:", streamErr);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||||
|
const {
|
||||||
|
sessionId,
|
||||||
|
path: filePath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !filePath) {
|
||||||
|
fileLogger.warn("Missing download parameters", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
hasFilePath: !!filePath,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Missing download parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
if (!sshConn || !sshConn.isConnected) {
|
||||||
|
fileLogger.warn("SSH session not found or not connected for download", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
isConnected: sshConn?.isConnected,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "SSH session not found or not connected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
// Use SFTP to read file for binary safety
|
||||||
|
sshConn.client.sftp((err, sftp) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SFTP connection failed for download:", err);
|
||||||
|
return res.status(500).json({ error: "SFTP connection failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file stats first to check if it's a regular file and get size
|
||||||
|
sftp.stat(filePath, (statErr, stats) => {
|
||||||
|
if (statErr) {
|
||||||
|
fileLogger.error("File stat failed for download:", statErr);
|
||||||
|
return res.status(500).json({ error: `Cannot access file: ${statErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
fileLogger.warn("Attempted to download non-file", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
isFile: stats.isFile(),
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Cannot download directories or special files" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (limit to 100MB for safety)
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
fileLogger.warn("File too large for download", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
fileSize: stats.size,
|
||||||
|
maxSize: MAX_FILE_SIZE,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
sftp.readFile(filePath, (readErr, data) => {
|
||||||
|
if (readErr) {
|
||||||
|
fileLogger.error("File read failed for download:", readErr);
|
||||||
|
return res.status(500).json({ error: `Failed to read file: ${readErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64 for safe transport
|
||||||
|
const base64Content = data.toString('base64');
|
||||||
|
const fileName = filePath.split('/').pop() || 'download';
|
||||||
|
|
||||||
|
fileLogger.success("File downloaded successfully", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
fileSize: stats.size,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
content: base64Content,
|
||||||
|
fileName: fileName,
|
||||||
|
size: stats.size,
|
||||||
|
mimeType: getMimeType(fileName),
|
||||||
|
path: filePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy SSH file/directory
|
||||||
|
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||||
|
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !sourcePath || !targetDir) {
|
||||||
|
return res.status(400).json({ error: "Missing required parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
if (!sshConn || !sshConn.isConnected) {
|
||||||
|
return res.status(400).json({ error: "SSH session not found or not connected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract source name
|
||||||
|
const sourceName = sourcePath.split('/').pop() || 'copied_item';
|
||||||
|
|
||||||
|
// First check if source file exists
|
||||||
|
const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'");
|
||||||
|
const checkExistsCommand = `test -e '${escapedSourceForCheck}'`;
|
||||||
|
const checkExists = await new Promise<boolean>((resolve) => {
|
||||||
|
sshConn.client.exec(checkExistsCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("File existence check error:", err);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
fileLogger.info("File existence check completed", { sourcePath, exists: code === 0 });
|
||||||
|
resolve(code === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkExists) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Source file not found: ${sourcePath}`,
|
||||||
|
toast: { type: "error", message: `Source file not found: ${sourceName}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use timestamp for uniqueness
|
||||||
|
const timestamp = Date.now().toString().slice(-8);
|
||||||
|
const nameWithoutExt = sourceName.includes('.')
|
||||||
|
? sourceName.substring(0, sourceName.lastIndexOf('.'))
|
||||||
|
: sourceName;
|
||||||
|
const extension = sourceName.includes('.')
|
||||||
|
? sourceName.substring(sourceName.lastIndexOf('.'))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Always use timestamp suffix to ensure uniqueness without SSH calls
|
||||||
|
const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`;
|
||||||
|
|
||||||
|
fileLogger.info("Using timestamp-based unique name", { originalName: sourceName, uniqueName });
|
||||||
|
const targetPath = `${targetDir}/${uniqueName}`;
|
||||||
|
|
||||||
|
// Escape paths for shell commands
|
||||||
|
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||||
|
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
|
// Use cp with explicit flags to avoid hanging on prompts
|
||||||
|
// -f: force overwrite without prompting
|
||||||
|
// -r: recursive for directories
|
||||||
|
// -p: preserve timestamps, permissions
|
||||||
|
const copyCommand = `cp -fpr '${escapedSource}' '${escapedTarget}' 2>&1`;
|
||||||
|
|
||||||
|
fileLogger.info("Starting file copy operation", {
|
||||||
|
operation: "file_copy_start",
|
||||||
|
sessionId,
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
uniqueName,
|
||||||
|
command: copyCommand.substring(0, 200) + "..." // Log truncated command
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add timeout to prevent hanging
|
||||||
|
const commandTimeout = setTimeout(() => {
|
||||||
|
fileLogger.error("Copy command timed out after 20 seconds", {
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
command: copyCommand
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Copy operation timed out",
|
||||||
|
toast: { type: "error", message: "Copy operation timed out. SSH connection may be unstable." }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 20000); // 20 second timeout for better responsiveness
|
||||||
|
|
||||||
|
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(commandTimeout);
|
||||||
|
fileLogger.error("SSH copyItem error:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorData = "";
|
||||||
|
let stdoutData = "";
|
||||||
|
|
||||||
|
// Monitor both stdout and stderr
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
const output = data.toString();
|
||||||
|
stdoutData += output;
|
||||||
|
fileLogger.info("Copy command stdout", { output: output.substring(0, 200) });
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
|
const output = data.toString();
|
||||||
|
errorData += output;
|
||||||
|
fileLogger.info("Copy command stderr", { output: output.substring(0, 200) });
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
clearTimeout(commandTimeout);
|
||||||
|
fileLogger.info("Copy command completed", { code, errorData, hasError: errorData.length > 0 });
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const fullErrorInfo = errorData || stdoutData || 'No error message available';
|
||||||
|
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
|
||||||
|
operation: "file_copy_failed",
|
||||||
|
sessionId,
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
command: copyCommand,
|
||||||
|
exitCode: code,
|
||||||
|
errorData,
|
||||||
|
stdoutData,
|
||||||
|
fullErrorInfo
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: `Copy failed: ${fullErrorInfo}`,
|
||||||
|
toast: { type: "error", message: `Copy failed: ${fullErrorInfo}` },
|
||||||
|
debug: {
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
exitCode: code,
|
||||||
|
command: copyCommand
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.success("Item copied successfully", {
|
||||||
|
operation: "file_copy",
|
||||||
|
sessionId,
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
uniqueName,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.json({
|
||||||
|
message: "Item copied successfully",
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
uniqueName,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `Successfully copied to: ${uniqueName}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
clearTimeout(commandTimeout);
|
||||||
|
fileLogger.error("SSH copyItem stream error:", streamErr);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
fileLogger.error("Copy operation error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to determine MIME type based on file extension
|
||||||
|
function getMimeType(fileName: string): string {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'json': 'application/json',
|
||||||
|
'js': 'text/javascript',
|
||||||
|
'html': 'text/html',
|
||||||
|
'css': 'text/css',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
};
|
||||||
|
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
Object.keys(sshSessions).forEach(cleanupSession);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -1279,6 +1868,108 @@ process.on("SIGTERM", () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 执行可执行文件
|
||||||
|
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||||
|
const { sessionId, filePath, hostId, userId } = req.body;
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
|
if (!sshConn || !sshConn.isConnected) {
|
||||||
|
fileLogger.error("SSH connection not found or not connected for executeFile", {
|
||||||
|
operation: "execute_file",
|
||||||
|
sessionId,
|
||||||
|
hasConnection: !!sshConn,
|
||||||
|
isConnected: sshConn?.isConnected
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "SSH connection not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return res.status(400).json({ error: "File path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
|
// 检查文件是否存在且可执行
|
||||||
|
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||||
|
|
||||||
|
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||||
|
if (checkErr) {
|
||||||
|
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||||
|
return res.status(500).json({ error: "Failed to check file executability" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkResult = '';
|
||||||
|
checkStream.on("data", (data) => {
|
||||||
|
checkResult += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
checkStream.on("close", (code) => {
|
||||||
|
if (!checkResult.includes("EXECUTABLE")) {
|
||||||
|
return res.status(400).json({ error: "File is not executable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行文件
|
||||||
|
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||||
|
|
||||||
|
fileLogger.info("Executing file", {
|
||||||
|
operation: "execute_file",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
command: executeCommand.substring(0, 100) + "..."
|
||||||
|
});
|
||||||
|
|
||||||
|
sshConn.client.exec(executeCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH executeFile error:", err);
|
||||||
|
return res.status(500).json({ error: "Failed to execute file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
// 从输出中提取退出代码
|
||||||
|
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||||
|
const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code;
|
||||||
|
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, '').trim();
|
||||||
|
|
||||||
|
fileLogger.info("File execution completed", {
|
||||||
|
operation: "execute_file",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
exitCode: actualExitCode,
|
||||||
|
outputLength: cleanOutput.length,
|
||||||
|
errorLength: errorOutput.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
exitCode: actualExitCode,
|
||||||
|
output: cleanOutput,
|
||||||
|
error: errorOutput,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Execution stream error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = 8084;
|
const PORT = 8084;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
fileLogger.success("File Manager API server started", {
|
fileLogger.success("File Manager API server started", {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db } from "../database/db/index.js";
|
|||||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||||
|
|
||||||
interface PooledConnection {
|
interface PooledConnection {
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -306,7 +307,10 @@ const hostStatuses: Map<number, StatusEntry> = new Map();
|
|||||||
|
|
||||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||||
try {
|
try {
|
||||||
const hosts = await db.select().from(sshData);
|
const hosts = await EncryptedDBOperations.select(
|
||||||
|
db.select().from(sshData),
|
||||||
|
'ssh_data'
|
||||||
|
);
|
||||||
|
|
||||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
@@ -333,7 +337,10 @@ async function fetchHostById(
|
|||||||
id: number,
|
id: number,
|
||||||
): Promise<SSHHostWithCredentials | undefined> {
|
): Promise<SSHHostWithCredentials | undefined> {
|
||||||
try {
|
try {
|
||||||
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
|
const hosts = await EncryptedDBOperations.select(
|
||||||
|
db.select().from(sshData).where(eq(sshData.id, id)),
|
||||||
|
'ssh_data'
|
||||||
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hosts.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -351,6 +358,17 @@ async function resolveHostCredentials(
|
|||||||
host: any,
|
host: any,
|
||||||
): Promise<SSHHostWithCredentials | undefined> {
|
): Promise<SSHHostWithCredentials | undefined> {
|
||||||
try {
|
try {
|
||||||
|
statsLogger.debug(`Resolving credentials for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
hostId: host.id,
|
||||||
|
authType: host.authType,
|
||||||
|
credentialId: host.credentialId,
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
const baseHost: any = {
|
const baseHost: any = {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
name: host.name,
|
name: host.name,
|
||||||
@@ -380,18 +398,26 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(and(
|
||||||
.from(sshCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sshCredentials.id, host.credentialId),
|
eq(sshCredentials.id, host.credentialId),
|
||||||
eq(sshCredentials.userId, host.userId),
|
eq(sshCredentials.userId, host.userId),
|
||||||
),
|
)),
|
||||||
|
'ssh_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
|
statsLogger.debug(`Using credential ${credential.id} for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
credentialId: credential.id,
|
||||||
|
authType: credential.authType,
|
||||||
|
hasPassword: !!credential.password,
|
||||||
|
hasKey: !!credential.key,
|
||||||
|
passwordLength: credential.password?.length || 0,
|
||||||
|
keyLength: credential.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
baseHost.credentialId = credential.id;
|
baseHost.credentialId = credential.id;
|
||||||
baseHost.username = credential.username;
|
baseHost.username = credential.username;
|
||||||
baseHost.authType = credential.authType;
|
baseHost.authType = credential.authType;
|
||||||
@@ -421,9 +447,25 @@ async function resolveHostCredentials(
|
|||||||
addLegacyCredentials(baseHost, host);
|
addLegacyCredentials(baseHost, host);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
statsLogger.debug(`Using legacy credentials for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0
|
||||||
|
});
|
||||||
addLegacyCredentials(baseHost, host);
|
addLegacyCredentials(baseHost, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsLogger.debug(`Final resolved host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
authType: baseHost.authType,
|
||||||
|
hasPassword: !!baseHost.password,
|
||||||
|
hasKey: !!baseHost.key,
|
||||||
|
passwordLength: baseHost.password?.length || 0,
|
||||||
|
keyLength: baseHost.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
return baseHost;
|
return baseHost;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statsLogger.error(
|
statsLogger.error(
|
||||||
@@ -441,6 +483,18 @@ function addLegacyCredentials(baseHost: any, host: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
|
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
authType: host.authType,
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
username: host.username,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0,
|
||||||
|
passwordType: typeof host.password,
|
||||||
|
passwordRaw: host.password ? JSON.stringify(host.password.substring(0, 20)) : null
|
||||||
|
});
|
||||||
|
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
@@ -453,12 +507,26 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
if (!host.password) {
|
if (!host.password) {
|
||||||
throw new Error(`No password available for host ${host.ip}`);
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
statsLogger.debug(`Using password auth for ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
passwordLength: host.password.length,
|
||||||
|
passwordFirst3: host.password.substring(0, 3),
|
||||||
|
passwordLast3: host.password.substring(host.password.length - 3),
|
||||||
|
passwordType: typeof host.password,
|
||||||
|
passwordIsString: typeof host.password === 'string'
|
||||||
|
});
|
||||||
(base as any).password = host.password;
|
(base as any).password = host.password;
|
||||||
} else if (host.authType === "key") {
|
} else if (host.authType === "key") {
|
||||||
if (!host.key) {
|
if (!host.key) {
|
||||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsLogger.debug(`Using key auth for ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + '...',
|
||||||
|
hasPassphrase: !!host.keyPassword
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
||||||
throw new Error("Invalid private key format");
|
throw new Error("Invalid private key format");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "../database/db/index.js";
|
|||||||
import { sshCredentials } from "../database/db/schema.js";
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 8082 });
|
const wss = new WebSocketServer({ port: 8082 });
|
||||||
|
|
||||||
@@ -103,8 +104,10 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
initialPath?: string;
|
||||||
|
executeCommand?: string;
|
||||||
}) {
|
}) {
|
||||||
const { cols, rows, hostConfig } = data;
|
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
ip,
|
ip,
|
||||||
@@ -174,24 +177,44 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
sshLogger.debug(`Terminal SSH setup`, {
|
||||||
|
operation: 'terminal_ssh',
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
authType,
|
||||||
|
hasPassword: !!password,
|
||||||
|
passwordLength: password?.length || 0,
|
||||||
|
hasCredentialId: !!credentialId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
||||||
|
operation: 'terminal_ssh_password'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sshLogger.debug(`No password provided`, {
|
||||||
|
operation: 'terminal_ssh_password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
if (credentialId && id && hostConfig.userId) {
|
if (credentialId && id && hostConfig.userId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(
|
||||||
.from(sshCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, hostConfig.userId),
|
eq(sshCredentials.userId, hostConfig.userId),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
'ssh_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
@@ -281,6 +304,34 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
|
|
||||||
setupPingInterval();
|
setupPingInterval();
|
||||||
|
|
||||||
|
// Change to initial path if specified
|
||||||
|
if (initialPath && initialPath.trim() !== "") {
|
||||||
|
sshLogger.debug(`Changing to initial path: ${initialPath}`, {
|
||||||
|
operation: "ssh_initial_path",
|
||||||
|
hostId: id,
|
||||||
|
path: initialPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send cd command to change directory
|
||||||
|
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||||
|
stream.write(cdCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute command if specified
|
||||||
|
if (executeCommand && executeCommand.trim() !== "") {
|
||||||
|
sshLogger.debug(`Executing command: ${executeCommand}`, {
|
||||||
|
operation: "ssh_execute_command",
|
||||||
|
hostId: id,
|
||||||
|
command: executeCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a moment for the cd command to complete, then execute the command
|
||||||
|
setTimeout(() => {
|
||||||
|
const command = `${executeCommand}\n`;
|
||||||
|
stream.write(command);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedSourceCredentials = {
|
resolvedSourceCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
@@ -501,7 +501,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedEndpointCredentials = {
|
resolvedEndpointCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
|
|||||||
+14
-5
@@ -2,10 +2,7 @@
|
|||||||
// node ./dist/backend/starter.js
|
// node ./dist/backend/starter.js
|
||||||
|
|
||||||
import "./database/database.js";
|
import "./database/database.js";
|
||||||
import "./ssh/terminal.js";
|
import { DatabaseEncryption } from "./utils/database-encryption.js";
|
||||||
import "./ssh/tunnel.js";
|
|
||||||
import "./ssh/file-manager.js";
|
|
||||||
import "./ssh/server-stats.js";
|
|
||||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
@@ -21,9 +18,21 @@ import "dotenv/config";
|
|||||||
operation: "startup",
|
operation: "startup",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize database encryption before other services
|
||||||
|
await DatabaseEncryption.initialize();
|
||||||
|
systemLogger.info("Database encryption initialized", {
|
||||||
|
operation: "encryption_init",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load modules that depend on encryption after initialization
|
||||||
|
await import("./ssh/terminal.js");
|
||||||
|
await import("./ssh/tunnel.js");
|
||||||
|
await import("./ssh/file-manager.js");
|
||||||
|
await import("./ssh/server-stats.js");
|
||||||
|
|
||||||
systemLogger.success("All backend services initialized successfully", {
|
systemLogger.success("All backend services initialized successfully", {
|
||||||
operation: "startup_complete",
|
operation: "startup_complete",
|
||||||
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
services: ["database", "encryption", "terminal", "tunnel", "file_manager", "stats"],
|
||||||
version: version,
|
version: version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { FieldEncryption } from './encryption.js';
|
||||||
|
import { EncryptionKeyManager } from './encryption-key-manager.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
|
||||||
|
interface EncryptionContext {
|
||||||
|
masterPassword: string;
|
||||||
|
encryptionEnabled: boolean;
|
||||||
|
forceEncryption: boolean;
|
||||||
|
migrateOnAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DatabaseEncryption {
|
||||||
|
private static context: EncryptionContext | null = null;
|
||||||
|
|
||||||
|
static async initialize(config: Partial<EncryptionContext> = {}) {
|
||||||
|
const keyManager = EncryptionKeyManager.getInstance();
|
||||||
|
const masterPassword = config.masterPassword || await keyManager.initializeKey();
|
||||||
|
|
||||||
|
this.context = {
|
||||||
|
masterPassword,
|
||||||
|
encryptionEnabled: config.encryptionEnabled ?? true,
|
||||||
|
forceEncryption: config.forceEncryption ?? false,
|
||||||
|
migrateOnAccess: config.migrateOnAccess ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
databaseLogger.info('Database encryption initialized', {
|
||||||
|
operation: 'encryption_init',
|
||||||
|
enabled: this.context.encryptionEnabled,
|
||||||
|
forceEncryption: this.context.forceEncryption,
|
||||||
|
dynamicKey: !config.masterPassword
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getContext(): EncryptionContext {
|
||||||
|
if (!this.context) {
|
||||||
|
throw new Error('DatabaseEncryption not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
static encryptRecord(tableName: string, record: any): any {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (!context.encryptionEnabled) return record;
|
||||||
|
|
||||||
|
const encryptedRecord = { ...record };
|
||||||
|
let hasEncryption = false;
|
||||||
|
|
||||||
|
for (const [fieldName, value] of Object.entries(record)) {
|
||||||
|
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
|
||||||
|
try {
|
||||||
|
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`);
|
||||||
|
encryptedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey);
|
||||||
|
hasEncryption = true;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to encrypt field ${tableName}.${fieldName}`, error, {
|
||||||
|
operation: 'field_encryption',
|
||||||
|
table: tableName,
|
||||||
|
field: fieldName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEncryption) {
|
||||||
|
databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, {
|
||||||
|
operation: 'record_encryption',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
static decryptRecord(tableName: string, record: any): any {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (!record) return record;
|
||||||
|
|
||||||
|
const decryptedRecord = { ...record };
|
||||||
|
let hasDecryption = false;
|
||||||
|
let needsMigration = false;
|
||||||
|
|
||||||
|
for (const [fieldName, value] of Object.entries(record)) {
|
||||||
|
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
|
||||||
|
try {
|
||||||
|
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`);
|
||||||
|
|
||||||
|
if (FieldEncryption.isEncrypted(value as string)) {
|
||||||
|
decryptedRecord[fieldName] = FieldEncryption.decryptField(value as string, fieldKey);
|
||||||
|
hasDecryption = true;
|
||||||
|
} else if (context.encryptionEnabled && !context.forceEncryption) {
|
||||||
|
decryptedRecord[fieldName] = value;
|
||||||
|
needsMigration = context.migrateOnAccess;
|
||||||
|
} else if (context.forceEncryption) {
|
||||||
|
databaseLogger.warn(`Unencrypted field detected in force encryption mode`, {
|
||||||
|
operation: 'decryption_warning',
|
||||||
|
table: tableName,
|
||||||
|
field: fieldName
|
||||||
|
});
|
||||||
|
decryptedRecord[fieldName] = value;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to decrypt field ${tableName}.${fieldName}`, error, {
|
||||||
|
operation: 'field_decryption',
|
||||||
|
table: tableName,
|
||||||
|
field: fieldName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.forceEncryption) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
decryptedRecord[fieldName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDecryption) {
|
||||||
|
databaseLogger.debug(`Decrypted sensitive fields for ${tableName}`, {
|
||||||
|
operation: 'record_decryption',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsMigration) {
|
||||||
|
this.scheduleFieldMigration(tableName, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decryptedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
static decryptRecords(tableName: string, records: any[]): any[] {
|
||||||
|
if (!Array.isArray(records)) return records;
|
||||||
|
return records.map(record => this.decryptRecord(tableName, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static scheduleFieldMigration(tableName: string, record: any) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await this.migrateRecord(tableName, record);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to migrate record ${tableName}:${record.id}`, error, {
|
||||||
|
operation: 'migration_failed',
|
||||||
|
table: tableName,
|
||||||
|
recordId: record.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async migrateRecord(tableName: string, record: any): Promise<any> {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (!context.encryptionEnabled || !context.migrateOnAccess) return record;
|
||||||
|
|
||||||
|
let needsUpdate = false;
|
||||||
|
const updatedRecord = { ...record };
|
||||||
|
|
||||||
|
for (const [fieldName, value] of Object.entries(record)) {
|
||||||
|
if (FieldEncryption.shouldEncryptField(tableName, fieldName) &&
|
||||||
|
value && !FieldEncryption.isEncrypted(value as string)) {
|
||||||
|
try {
|
||||||
|
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`);
|
||||||
|
updatedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey);
|
||||||
|
needsUpdate = true;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to migrate field ${tableName}.${fieldName}`, error, {
|
||||||
|
operation: 'field_migration',
|
||||||
|
table: tableName,
|
||||||
|
field: fieldName,
|
||||||
|
recordId: record.id
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
databaseLogger.info(`Migrated record to encrypted format`, {
|
||||||
|
operation: 'record_migration',
|
||||||
|
table: tableName,
|
||||||
|
recordId: record.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateConfiguration(): boolean {
|
||||||
|
try {
|
||||||
|
const context = this.getContext();
|
||||||
|
const testData = 'test-encryption-data';
|
||||||
|
const testKey = FieldEncryption.getFieldKey(context.masterPassword, 'test');
|
||||||
|
|
||||||
|
const encrypted = FieldEncryption.encryptField(testData, testKey);
|
||||||
|
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
|
||||||
|
|
||||||
|
return decrypted === testData;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Encryption configuration validation failed', error, {
|
||||||
|
operation: 'config_validation'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEncryptionStatus() {
|
||||||
|
try {
|
||||||
|
const context = this.getContext();
|
||||||
|
return {
|
||||||
|
enabled: context.encryptionEnabled,
|
||||||
|
forceEncryption: context.forceEncryption,
|
||||||
|
migrateOnAccess: context.migrateOnAccess,
|
||||||
|
configValid: this.validateConfiguration()
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
forceEncryption: false,
|
||||||
|
migrateOnAccess: false,
|
||||||
|
configValid: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getDetailedStatus() {
|
||||||
|
const keyManager = EncryptionKeyManager.getInstance();
|
||||||
|
const keyStatus = await keyManager.getEncryptionStatus();
|
||||||
|
const encryptionStatus = this.getEncryptionStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...encryptionStatus,
|
||||||
|
key: keyStatus,
|
||||||
|
initialized: this.context !== null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async reinitializeWithNewKey(): Promise<void> {
|
||||||
|
const keyManager = EncryptionKeyManager.getInstance();
|
||||||
|
const newKey = await keyManager.regenerateKey();
|
||||||
|
|
||||||
|
this.context = null;
|
||||||
|
await this.initialize({ masterPassword: newKey });
|
||||||
|
|
||||||
|
databaseLogger.warn('Database encryption reinitialized with new key', {
|
||||||
|
operation: 'encryption_reinit',
|
||||||
|
requiresMigration: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DatabaseEncryption };
|
||||||
|
export type { EncryptionContext };
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { HardwareFingerprint } from './hardware-fingerprint.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
|
||||||
|
interface EncryptedFileMetadata {
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
salt: string;
|
||||||
|
algorithm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database file encryption - encrypts the entire SQLite database file at rest
|
||||||
|
* This provides an additional security layer on top of field-level encryption
|
||||||
|
*/
|
||||||
|
class DatabaseFileEncryption {
|
||||||
|
private static readonly VERSION = 'v1';
|
||||||
|
private static readonly ALGORITHM = 'aes-256-gcm';
|
||||||
|
private static readonly KEY_ITERATIONS = 100000;
|
||||||
|
private static readonly ENCRYPTED_FILE_SUFFIX = '.encrypted';
|
||||||
|
private static readonly METADATA_FILE_SUFFIX = '.meta';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate file encryption key from hardware fingerprint
|
||||||
|
*/
|
||||||
|
private static generateFileEncryptionKey(salt: Buffer): Buffer {
|
||||||
|
const hardwareFingerprint = HardwareFingerprint.generate();
|
||||||
|
|
||||||
|
const key = crypto.pbkdf2Sync(
|
||||||
|
hardwareFingerprint,
|
||||||
|
salt,
|
||||||
|
this.KEY_ITERATIONS,
|
||||||
|
32, // 256 bits for AES-256
|
||||||
|
'sha256'
|
||||||
|
);
|
||||||
|
|
||||||
|
databaseLogger.debug('Generated file encryption key from hardware fingerprint', {
|
||||||
|
operation: 'file_key_generation',
|
||||||
|
iterations: this.KEY_ITERATIONS,
|
||||||
|
keyLength: key.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt database from buffer (for in-memory databases)
|
||||||
|
*/
|
||||||
|
static encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): string {
|
||||||
|
try {
|
||||||
|
// Generate encryption components
|
||||||
|
const salt = crypto.randomBytes(32);
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const key = this.generateFileEncryptionKey(salt);
|
||||||
|
|
||||||
|
// Encrypt the buffer
|
||||||
|
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(buffer),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Create metadata
|
||||||
|
const metadata: EncryptedFileMetadata = {
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex'),
|
||||||
|
version: this.VERSION,
|
||||||
|
fingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||||
|
salt: salt.toString('hex'),
|
||||||
|
algorithm: this.ALGORITHM
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write encrypted file and metadata
|
||||||
|
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
|
fs.writeFileSync(targetPath, encrypted);
|
||||||
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
|
||||||
|
databaseLogger.info('Database buffer encrypted successfully', {
|
||||||
|
operation: 'database_buffer_encryption',
|
||||||
|
targetPath,
|
||||||
|
bufferSize: buffer.length,
|
||||||
|
encryptedSize: encrypted.length,
|
||||||
|
fingerprintPrefix: metadata.fingerprint
|
||||||
|
});
|
||||||
|
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt database file
|
||||||
|
*/
|
||||||
|
static encryptDatabaseFile(sourcePath: string, targetPath?: string): 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 {
|
||||||
|
// Read source file
|
||||||
|
const sourceData = fs.readFileSync(sourcePath);
|
||||||
|
|
||||||
|
// Generate encryption components
|
||||||
|
const salt = crypto.randomBytes(32);
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const key = this.generateFileEncryptionKey(salt);
|
||||||
|
|
||||||
|
// Encrypt the file
|
||||||
|
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(sourceData),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Create metadata
|
||||||
|
const metadata: EncryptedFileMetadata = {
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex'),
|
||||||
|
version: this.VERSION,
|
||||||
|
fingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||||
|
salt: salt.toString('hex'),
|
||||||
|
algorithm: this.ALGORITHM
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write encrypted file and metadata
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt database file to buffer (for in-memory usage)
|
||||||
|
*/
|
||||||
|
static decryptDatabaseToBuffer(encryptedPath: string): 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 {
|
||||||
|
// Read metadata
|
||||||
|
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
|
||||||
|
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
|
// Validate metadata version
|
||||||
|
if (metadata.version !== this.VERSION) {
|
||||||
|
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hardware fingerprint
|
||||||
|
const currentFingerprint = HardwareFingerprint.generate().substring(0, 16);
|
||||||
|
if (metadata.fingerprint !== currentFingerprint) {
|
||||||
|
databaseLogger.warn('Hardware fingerprint mismatch for database buffer decryption', {
|
||||||
|
operation: 'database_buffer_decryption',
|
||||||
|
expected: metadata.fingerprint,
|
||||||
|
current: currentFingerprint
|
||||||
|
});
|
||||||
|
throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read encrypted data
|
||||||
|
const encryptedData = fs.readFileSync(encryptedPath);
|
||||||
|
|
||||||
|
// Generate decryption key
|
||||||
|
const salt = Buffer.from(metadata.salt, 'hex');
|
||||||
|
const key = this.generateFileEncryptionKey(salt);
|
||||||
|
|
||||||
|
// Decrypt to buffer
|
||||||
|
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()
|
||||||
|
]);
|
||||||
|
|
||||||
|
databaseLogger.info('Database decrypted to memory buffer', {
|
||||||
|
operation: 'database_buffer_decryption',
|
||||||
|
encryptedPath,
|
||||||
|
encryptedSize: encryptedData.length,
|
||||||
|
decryptedSize: decryptedBuffer.length,
|
||||||
|
fingerprintPrefix: metadata.fingerprint
|
||||||
|
});
|
||||||
|
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt database file
|
||||||
|
*/
|
||||||
|
static decryptDatabaseFile(encryptedPath: string, targetPath?: string): 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 {
|
||||||
|
// Read metadata
|
||||||
|
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
|
||||||
|
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
|
// Validate metadata version
|
||||||
|
if (metadata.version !== this.VERSION) {
|
||||||
|
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hardware fingerprint
|
||||||
|
const currentFingerprint = HardwareFingerprint.generate().substring(0, 16);
|
||||||
|
if (metadata.fingerprint !== currentFingerprint) {
|
||||||
|
databaseLogger.warn('Hardware fingerprint mismatch for database file', {
|
||||||
|
operation: 'database_file_decryption',
|
||||||
|
expected: metadata.fingerprint,
|
||||||
|
current: currentFingerprint
|
||||||
|
});
|
||||||
|
throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read encrypted data
|
||||||
|
const encryptedData = fs.readFileSync(encryptedPath);
|
||||||
|
|
||||||
|
// Generate decryption key
|
||||||
|
const salt = Buffer.from(metadata.salt, 'hex');
|
||||||
|
const key = this.generateFileEncryptionKey(salt);
|
||||||
|
|
||||||
|
// Decrypt the file
|
||||||
|
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()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Write decrypted file
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is an encrypted database file
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get information about an encrypted database file
|
||||||
|
*/
|
||||||
|
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 = HardwareFingerprint.generate().substring(0, 16);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: metadata.version,
|
||||||
|
algorithm: metadata.algorithm,
|
||||||
|
fingerprint: metadata.fingerprint,
|
||||||
|
isCurrentHardware: metadata.fingerprint === currentFingerprint,
|
||||||
|
fileSize: fileStats.size
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Securely backup database by creating encrypted copy
|
||||||
|
*/
|
||||||
|
static createEncryptedBackup(databasePath: string, backupDir: string): string {
|
||||||
|
if (!fs.existsSync(databasePath)) {
|
||||||
|
throw new Error(`Database file does not exist: ${databasePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure backup directory exists
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backup filename with timestamp
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
||||||
|
const backupPath = path.join(backupDir, backupFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptedPath = this.encryptDatabaseFile(databasePath, backupPath);
|
||||||
|
|
||||||
|
databaseLogger.info('Encrypted database backup created', {
|
||||||
|
operation: 'database_backup',
|
||||||
|
sourcePath: databasePath,
|
||||||
|
backupPath: encryptedPath,
|
||||||
|
timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
return encryptedPath;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to create encrypted backup', error, {
|
||||||
|
operation: 'database_backup_failed',
|
||||||
|
sourcePath: databasePath,
|
||||||
|
backupDir
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore database from encrypted backup
|
||||||
|
*/
|
||||||
|
static restoreFromEncryptedBackup(backupPath: string, targetPath: string): string {
|
||||||
|
if (!this.isEncryptedDatabaseFile(backupPath)) {
|
||||||
|
throw new Error('Invalid encrypted backup file');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restoredPath = this.decryptDatabaseFile(backupPath, targetPath);
|
||||||
|
|
||||||
|
databaseLogger.info('Database restored from encrypted backup', {
|
||||||
|
operation: 'database_restore',
|
||||||
|
backupPath,
|
||||||
|
restoredPath
|
||||||
|
});
|
||||||
|
|
||||||
|
return restoredPath;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to restore from encrypted backup', error, {
|
||||||
|
operation: 'database_restore_failed',
|
||||||
|
backupPath,
|
||||||
|
targetPath
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hardware compatibility for encrypted file
|
||||||
|
*/
|
||||||
|
static validateHardwareCompatibility(encryptedPath: string): boolean {
|
||||||
|
try {
|
||||||
|
const info = this.getEncryptedFileInfo(encryptedPath);
|
||||||
|
return info?.isCurrentHardware ?? false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up temporary files
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
databaseLogger.debug('Cleaned up temporary file', {
|
||||||
|
operation: 'temp_cleanup',
|
||||||
|
file: 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 };
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { DatabaseFileEncryption } from './database-file-encryption.js';
|
||||||
|
import { DatabaseEncryption } from './database-encryption.js';
|
||||||
|
import { FieldEncryption } from './encryption.js';
|
||||||
|
import { HardwareFingerprint } from './hardware-fingerprint.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import { db, databasePaths } from '../database/db/index.js';
|
||||||
|
import { users, sshData, sshCredentials, settings, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, sshCredentialUsage } from '../database/db/schema.js';
|
||||||
|
|
||||||
|
interface ExportMetadata {
|
||||||
|
version: string;
|
||||||
|
exportedAt: string;
|
||||||
|
exportId: string;
|
||||||
|
sourceHardwareFingerprint: string;
|
||||||
|
tableCount: number;
|
||||||
|
recordCount: number;
|
||||||
|
encryptedFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationExport {
|
||||||
|
metadata: ExportMetadata;
|
||||||
|
data: {
|
||||||
|
[tableName: string]: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
success: boolean;
|
||||||
|
imported: {
|
||||||
|
tables: number;
|
||||||
|
records: number;
|
||||||
|
};
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database migration utility for exporting/importing data between different hardware
|
||||||
|
* Handles both field-level and file-level encryption/decryption during migration
|
||||||
|
*/
|
||||||
|
class DatabaseMigration {
|
||||||
|
private static readonly VERSION = 'v1';
|
||||||
|
private static readonly EXPORT_FILE_EXTENSION = '.termix-export.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export database for migration
|
||||||
|
* Decrypts all encrypted fields for transport to new hardware
|
||||||
|
*/
|
||||||
|
static async exportDatabase(exportPath?: string): Promise<string> {
|
||||||
|
const exportId = crypto.randomUUID();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const defaultExportPath = path.join(
|
||||||
|
databasePaths.directory,
|
||||||
|
`termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}`
|
||||||
|
);
|
||||||
|
const actualExportPath = exportPath || defaultExportPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.info('Starting database export for migration', {
|
||||||
|
operation: 'database_export',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define tables to export and their encryption status
|
||||||
|
const tablesToExport = [
|
||||||
|
{ name: 'users', table: users, hasEncryption: true },
|
||||||
|
{ name: 'ssh_data', table: sshData, hasEncryption: true },
|
||||||
|
{ name: 'ssh_credentials', table: sshCredentials, hasEncryption: true },
|
||||||
|
{ name: 'settings', table: settings, hasEncryption: false },
|
||||||
|
{ name: 'file_manager_recent', table: fileManagerRecent, hasEncryption: false },
|
||||||
|
{ name: 'file_manager_pinned', table: fileManagerPinned, hasEncryption: false },
|
||||||
|
{ name: 'file_manager_shortcuts', table: fileManagerShortcuts, hasEncryption: false },
|
||||||
|
{ name: 'dismissed_alerts', table: dismissedAlerts, hasEncryption: false },
|
||||||
|
{ name: 'ssh_credential_usage', table: sshCredentialUsage, hasEncryption: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const exportData: MigrationExport = {
|
||||||
|
metadata: {
|
||||||
|
version: this.VERSION,
|
||||||
|
exportedAt: timestamp,
|
||||||
|
exportId,
|
||||||
|
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||||
|
tableCount: 0,
|
||||||
|
recordCount: 0,
|
||||||
|
encryptedFields: []
|
||||||
|
},
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalRecords = 0;
|
||||||
|
|
||||||
|
// Export each table
|
||||||
|
for (const tableInfo of tablesToExport) {
|
||||||
|
try {
|
||||||
|
databaseLogger.debug(`Exporting table: ${tableInfo.name}`, {
|
||||||
|
operation: 'table_export',
|
||||||
|
table: tableInfo.name,
|
||||||
|
hasEncryption: tableInfo.hasEncryption
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query all records from the table
|
||||||
|
const records = await db.select().from(tableInfo.table);
|
||||||
|
|
||||||
|
// Decrypt encrypted fields if necessary
|
||||||
|
let processedRecords = records;
|
||||||
|
if (tableInfo.hasEncryption && records.length > 0) {
|
||||||
|
processedRecords = records.map(record => {
|
||||||
|
try {
|
||||||
|
return DatabaseEncryption.decryptRecord(tableInfo.name, record);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, {
|
||||||
|
operation: 'export_decrypt_warning',
|
||||||
|
table: tableInfo.name,
|
||||||
|
recordId: (record as any).id,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
// Return original record if decryption fails
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track which fields were encrypted
|
||||||
|
if (records.length > 0) {
|
||||||
|
const sampleRecord = records[0];
|
||||||
|
for (const fieldName of Object.keys(sampleRecord)) {
|
||||||
|
if (FieldEncryption.shouldEncryptField(tableInfo.name, fieldName)) {
|
||||||
|
const fieldKey = `${tableInfo.name}.${fieldName}`;
|
||||||
|
if (!exportData.metadata.encryptedFields.includes(fieldKey)) {
|
||||||
|
exportData.metadata.encryptedFields.push(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData.data[tableInfo.name] = processedRecords;
|
||||||
|
totalRecords += processedRecords.length;
|
||||||
|
|
||||||
|
databaseLogger.debug(`Table ${tableInfo.name} exported`, {
|
||||||
|
operation: 'table_export_complete',
|
||||||
|
table: tableInfo.name,
|
||||||
|
recordCount: processedRecords.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to export table ${tableInfo.name}`, error, {
|
||||||
|
operation: 'table_export_failed',
|
||||||
|
table: tableInfo.name
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
exportData.metadata.tableCount = tablesToExport.length;
|
||||||
|
exportData.metadata.recordCount = totalRecords;
|
||||||
|
|
||||||
|
// Write export file
|
||||||
|
const exportContent = JSON.stringify(exportData, null, 2);
|
||||||
|
fs.writeFileSync(actualExportPath, exportContent, 'utf8');
|
||||||
|
|
||||||
|
databaseLogger.success('Database export completed successfully', {
|
||||||
|
operation: 'database_export_complete',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath,
|
||||||
|
tableCount: exportData.metadata.tableCount,
|
||||||
|
recordCount: exportData.metadata.recordCount,
|
||||||
|
fileSize: exportContent.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return actualExportPath;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Database export failed', error, {
|
||||||
|
operation: 'database_export_failed',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath
|
||||||
|
});
|
||||||
|
throw new Error(`Database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import database from migration export
|
||||||
|
* Re-encrypts fields for the current hardware
|
||||||
|
*/
|
||||||
|
static async importDatabase(importPath: string, options: {
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
backupCurrent?: boolean;
|
||||||
|
} = {}): Promise<ImportResult> {
|
||||||
|
const { replaceExisting = false, backupCurrent = true } = options;
|
||||||
|
|
||||||
|
if (!fs.existsSync(importPath)) {
|
||||||
|
throw new Error(`Import file does not exist: ${importPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.info('Starting database import from migration export', {
|
||||||
|
operation: 'database_import',
|
||||||
|
importPath,
|
||||||
|
replaceExisting,
|
||||||
|
backupCurrent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read and validate export file
|
||||||
|
const exportContent = fs.readFileSync(importPath, 'utf8');
|
||||||
|
const exportData: MigrationExport = JSON.parse(exportContent);
|
||||||
|
|
||||||
|
// Validate export format
|
||||||
|
if (exportData.metadata.version !== this.VERSION) {
|
||||||
|
throw new Error(`Unsupported export version: ${exportData.metadata.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
success: false,
|
||||||
|
imported: { tables: 0, records: 0 },
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create backup if requested
|
||||||
|
if (backupCurrent) {
|
||||||
|
try {
|
||||||
|
const backupPath = await this.createCurrentDatabaseBackup();
|
||||||
|
databaseLogger.info('Current database backed up before import', {
|
||||||
|
operation: 'import_backup',
|
||||||
|
backupPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.warnings.push(warningMsg);
|
||||||
|
databaseLogger.warn('Failed to create pre-import backup', {
|
||||||
|
operation: 'import_backup_failed',
|
||||||
|
error: warningMsg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import data table by table
|
||||||
|
for (const [tableName, tableData] of Object.entries(exportData.data)) {
|
||||||
|
try {
|
||||||
|
databaseLogger.debug(`Importing table: ${tableName}`, {
|
||||||
|
operation: 'table_import',
|
||||||
|
table: tableName,
|
||||||
|
recordCount: tableData.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replaceExisting) {
|
||||||
|
// Clear existing data
|
||||||
|
const tableSchema = this.getTableSchema(tableName);
|
||||||
|
if (tableSchema) {
|
||||||
|
await db.delete(tableSchema);
|
||||||
|
databaseLogger.debug(`Cleared existing data from ${tableName}`, {
|
||||||
|
operation: 'table_clear',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process and encrypt records
|
||||||
|
for (const record of tableData) {
|
||||||
|
try {
|
||||||
|
// Re-encrypt sensitive fields for current hardware
|
||||||
|
const processedRecord = DatabaseEncryption.encryptRecord(tableName, record);
|
||||||
|
|
||||||
|
// Insert record
|
||||||
|
const tableSchema = this.getTableSchema(tableName);
|
||||||
|
if (tableSchema) {
|
||||||
|
await db.insert(tableSchema).values(processedRecord);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.errors.push(errorMsg);
|
||||||
|
databaseLogger.error('Failed to import record', error, {
|
||||||
|
operation: 'record_import_failed',
|
||||||
|
table: tableName,
|
||||||
|
recordId: record.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.imported.tables++;
|
||||||
|
result.imported.records += tableData.length;
|
||||||
|
|
||||||
|
databaseLogger.debug(`Table ${tableName} imported`, {
|
||||||
|
operation: 'table_import_complete',
|
||||||
|
table: tableName,
|
||||||
|
recordCount: tableData.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.errors.push(errorMsg);
|
||||||
|
databaseLogger.error('Failed to import table', error, {
|
||||||
|
operation: 'table_import_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if import was successful
|
||||||
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
databaseLogger.success('Database import completed successfully', {
|
||||||
|
operation: 'database_import_complete',
|
||||||
|
importPath,
|
||||||
|
tablesImported: result.imported.tables,
|
||||||
|
recordsImported: result.imported.records,
|
||||||
|
warnings: result.warnings.length
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
databaseLogger.error('Database import completed with errors', undefined, {
|
||||||
|
operation: 'database_import_partial',
|
||||||
|
importPath,
|
||||||
|
tablesImported: result.imported.tables,
|
||||||
|
recordsImported: result.imported.records,
|
||||||
|
errorCount: result.errors.length,
|
||||||
|
warningCount: result.warnings.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Database import failed', error, {
|
||||||
|
operation: 'database_import_failed',
|
||||||
|
importPath
|
||||||
|
});
|
||||||
|
throw new Error(`Database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate export file format and compatibility
|
||||||
|
*/
|
||||||
|
static validateExportFile(exportPath: string): {
|
||||||
|
valid: boolean;
|
||||||
|
metadata?: ExportMetadata;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const result = {
|
||||||
|
valid: false,
|
||||||
|
metadata: undefined as ExportMetadata | undefined,
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(exportPath)) {
|
||||||
|
result.errors.push('Export file does not exist');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportContent = fs.readFileSync(exportPath, 'utf8');
|
||||||
|
const exportData: MigrationExport = JSON.parse(exportContent);
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
if (!exportData.metadata || !exportData.data) {
|
||||||
|
result.errors.push('Invalid export file structure');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate version
|
||||||
|
if (exportData.metadata.version !== this.VERSION) {
|
||||||
|
result.errors.push(`Unsupported export version: ${exportData.metadata.version}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required metadata fields
|
||||||
|
const requiredFields = ['exportedAt', 'exportId', 'sourceHardwareFingerprint'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!exportData.metadata[field as keyof ExportMetadata]) {
|
||||||
|
result.errors.push(`Missing required metadata field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length === 0) {
|
||||||
|
result.valid = true;
|
||||||
|
result.metadata = exportData.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Failed to parse export file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backup of current database
|
||||||
|
*/
|
||||||
|
private static async createCurrentDatabaseBackup(): Promise<string> {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupDir = path.join(databasePaths.directory, 'backups');
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create encrypted backup
|
||||||
|
const backupPath = DatabaseFileEncryption.createEncryptedBackup(
|
||||||
|
databasePaths.main,
|
||||||
|
backupDir
|
||||||
|
);
|
||||||
|
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get table schema for database operations
|
||||||
|
*/
|
||||||
|
private static getTableSchema(tableName: string) {
|
||||||
|
const tableMap: { [key: string]: any } = {
|
||||||
|
'users': users,
|
||||||
|
'ssh_data': sshData,
|
||||||
|
'ssh_credentials': sshCredentials,
|
||||||
|
'settings': settings,
|
||||||
|
'file_manager_recent': fileManagerRecent,
|
||||||
|
'file_manager_pinned': fileManagerPinned,
|
||||||
|
'file_manager_shortcuts': fileManagerShortcuts,
|
||||||
|
'dismissed_alerts': dismissedAlerts,
|
||||||
|
'ssh_credential_usage': sshCredentialUsage
|
||||||
|
};
|
||||||
|
|
||||||
|
return tableMap[tableName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export file info without importing
|
||||||
|
*/
|
||||||
|
static getExportInfo(exportPath: string): ExportMetadata | null {
|
||||||
|
const validation = this.validateExportFile(exportPath);
|
||||||
|
return validation.valid ? validation.metadata! : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DatabaseMigration };
|
||||||
|
export type { ExportMetadata, MigrationExport, ImportResult };
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { sql, eq } from 'drizzle-orm';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { DatabaseEncryption } from './database-encryption.js';
|
||||||
|
import { FieldEncryption } from './encryption.js';
|
||||||
|
import { HardwareFingerprint } from './hardware-fingerprint.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import { databasePaths, db, sqliteInstance } from '../database/db/index.js';
|
||||||
|
import { sshData, sshCredentials, users } from '../database/db/schema.js';
|
||||||
|
|
||||||
|
interface ExportMetadata {
|
||||||
|
version: string;
|
||||||
|
exportedAt: string;
|
||||||
|
exportId: string;
|
||||||
|
sourceHardwareFingerprint: string;
|
||||||
|
tableCount: number;
|
||||||
|
recordCount: number;
|
||||||
|
encryptedFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
success: boolean;
|
||||||
|
imported: {
|
||||||
|
tables: number;
|
||||||
|
records: number;
|
||||||
|
};
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite database export/import utility for hardware migration
|
||||||
|
* Exports decrypted data to a new SQLite database file for hardware transfer
|
||||||
|
*/
|
||||||
|
class DatabaseSQLiteExport {
|
||||||
|
private static readonly VERSION = 'v1';
|
||||||
|
private static readonly EXPORT_FILE_EXTENSION = '.termix-export.sqlite';
|
||||||
|
private static readonly METADATA_TABLE = '_termix_export_metadata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export database as SQLite file for migration
|
||||||
|
* Creates a new SQLite database with decrypted data
|
||||||
|
*/
|
||||||
|
static async exportDatabase(exportPath?: string): Promise<string> {
|
||||||
|
const exportId = crypto.randomUUID();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const defaultExportPath = path.join(
|
||||||
|
databasePaths.directory,
|
||||||
|
`termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}`
|
||||||
|
);
|
||||||
|
const actualExportPath = exportPath || defaultExportPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.info('Starting SQLite database export for migration', {
|
||||||
|
operation: 'database_sqlite_export',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new SQLite database for export
|
||||||
|
const exportDb = new Database(actualExportPath);
|
||||||
|
|
||||||
|
// Define tables to export - only SSH-related data
|
||||||
|
const tablesToExport = [
|
||||||
|
{ name: 'ssh_data', hasEncryption: true },
|
||||||
|
{ name: 'ssh_credentials', hasEncryption: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const exportMetadata: ExportMetadata = {
|
||||||
|
version: this.VERSION,
|
||||||
|
exportedAt: timestamp,
|
||||||
|
exportId,
|
||||||
|
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||||
|
tableCount: 0,
|
||||||
|
recordCount: 0,
|
||||||
|
encryptedFields: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalRecords = 0;
|
||||||
|
|
||||||
|
// Check total records in SSH tables for debugging
|
||||||
|
const totalSshData = await db.select().from(sshData);
|
||||||
|
const totalSshCredentials = await db.select().from(sshCredentials);
|
||||||
|
|
||||||
|
databaseLogger.info(`Export preparation: found SSH data`, {
|
||||||
|
operation: 'export_data_check',
|
||||||
|
totalSshData: totalSshData.length,
|
||||||
|
totalSshCredentials: totalSshCredentials.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create metadata table
|
||||||
|
exportDb.exec(`
|
||||||
|
CREATE TABLE ${this.METADATA_TABLE} (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Copy schema and data for each table
|
||||||
|
for (const tableInfo of tablesToExport) {
|
||||||
|
try {
|
||||||
|
databaseLogger.debug(`Exporting SQLite table: ${tableInfo.name}`, {
|
||||||
|
operation: 'table_sqlite_export',
|
||||||
|
table: tableInfo.name,
|
||||||
|
hasEncryption: tableInfo.hasEncryption
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create table in export database using consistent schema
|
||||||
|
if (tableInfo.name === 'ssh_data') {
|
||||||
|
// Create ssh_data table using exact schema matching Drizzle definition
|
||||||
|
const createTableSql = `CREATE TABLE ssh_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
folder TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
pin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
auth_type TEXT NOT NULL,
|
||||||
|
password TEXT,
|
||||||
|
require_password INTEGER NOT NULL DEFAULT 1,
|
||||||
|
key TEXT,
|
||||||
|
key_password TEXT,
|
||||||
|
key_type TEXT,
|
||||||
|
credential_id INTEGER,
|
||||||
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
|
tunnel_connections TEXT,
|
||||||
|
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||||
|
default_path TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`;
|
||||||
|
exportDb.exec(createTableSql);
|
||||||
|
} else if (tableInfo.name === 'ssh_credentials') {
|
||||||
|
// Create ssh_credentials table using exact schema matching Drizzle definition
|
||||||
|
const createTableSql = `CREATE TABLE ssh_credentials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
password TEXT,
|
||||||
|
key_content TEXT,
|
||||||
|
key_password TEXT,
|
||||||
|
key_type TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`;
|
||||||
|
exportDb.exec(createTableSql);
|
||||||
|
} else {
|
||||||
|
databaseLogger.warn(`Unknown table ${tableInfo.name}, skipping`, {
|
||||||
|
operation: 'table_sqlite_export_skip',
|
||||||
|
table: tableInfo.name
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all records from tables using Drizzle
|
||||||
|
let records: any[];
|
||||||
|
if (tableInfo.name === 'ssh_data') {
|
||||||
|
records = await db.select().from(sshData);
|
||||||
|
} else if (tableInfo.name === 'ssh_credentials') {
|
||||||
|
records = await db.select().from(sshCredentials);
|
||||||
|
} else {
|
||||||
|
records = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.info(`Found ${records.length} records in ${tableInfo.name} for export`, {
|
||||||
|
operation: 'table_record_count',
|
||||||
|
table: tableInfo.name,
|
||||||
|
recordCount: records.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decrypt encrypted fields if necessary
|
||||||
|
let processedRecords = records;
|
||||||
|
if (tableInfo.hasEncryption && records.length > 0) {
|
||||||
|
processedRecords = records.map(record => {
|
||||||
|
try {
|
||||||
|
return DatabaseEncryption.decryptRecord(tableInfo.name, record);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, {
|
||||||
|
operation: 'export_decrypt_warning',
|
||||||
|
table: tableInfo.name,
|
||||||
|
recordId: (record as any).id,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track encrypted fields
|
||||||
|
const sampleRecord = records[0];
|
||||||
|
for (const fieldName of Object.keys(sampleRecord)) {
|
||||||
|
if (this.shouldTrackEncryptedField(tableInfo.name, fieldName)) {
|
||||||
|
const fieldKey = `${tableInfo.name}.${fieldName}`;
|
||||||
|
if (!exportMetadata.encryptedFields.includes(fieldKey)) {
|
||||||
|
exportMetadata.encryptedFields.push(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert records into export database
|
||||||
|
if (processedRecords.length > 0) {
|
||||||
|
const sampleRecord = processedRecords[0];
|
||||||
|
const tsFieldNames = Object.keys(sampleRecord);
|
||||||
|
|
||||||
|
// Map TypeScript field names to database column names
|
||||||
|
const dbColumnNames = tsFieldNames.map(fieldName => {
|
||||||
|
// Map TypeScript field names to database column names
|
||||||
|
const fieldMappings: Record<string, string> = {
|
||||||
|
'userId': 'user_id',
|
||||||
|
'authType': 'auth_type',
|
||||||
|
'requirePassword': 'require_password',
|
||||||
|
'keyPassword': 'key_password',
|
||||||
|
'keyType': 'key_type',
|
||||||
|
'credentialId': 'credential_id',
|
||||||
|
'enableTerminal': 'enable_terminal',
|
||||||
|
'enableTunnel': 'enable_tunnel',
|
||||||
|
'tunnelConnections': 'tunnel_connections',
|
||||||
|
'enableFileManager': 'enable_file_manager',
|
||||||
|
'defaultPath': 'default_path',
|
||||||
|
'createdAt': 'created_at',
|
||||||
|
'updatedAt': 'updated_at',
|
||||||
|
'keyContent': 'key_content'
|
||||||
|
};
|
||||||
|
return fieldMappings[fieldName] || fieldName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholders = dbColumnNames.map(() => '?').join(', ');
|
||||||
|
const insertSql = `INSERT INTO ${tableInfo.name} (${dbColumnNames.join(', ')}) VALUES (${placeholders})`;
|
||||||
|
|
||||||
|
const insertStmt = exportDb.prepare(insertSql);
|
||||||
|
|
||||||
|
for (const record of processedRecords) {
|
||||||
|
const values = tsFieldNames.map(fieldName => {
|
||||||
|
const value: any = record[fieldName as keyof typeof record];
|
||||||
|
// Convert values to SQLite-compatible types
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 1 : 0;
|
||||||
|
}
|
||||||
|
// Convert objects and arrays to JSON strings
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
// Fallback: convert to string
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
insertStmt.run(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRecords += processedRecords.length;
|
||||||
|
|
||||||
|
databaseLogger.debug(`SQLite table ${tableInfo.name} exported`, {
|
||||||
|
operation: 'table_sqlite_export_complete',
|
||||||
|
table: tableInfo.name,
|
||||||
|
recordCount: processedRecords.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to export SQLite table ${tableInfo.name}`, error, {
|
||||||
|
operation: 'table_sqlite_export_failed',
|
||||||
|
table: tableInfo.name
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update and store metadata
|
||||||
|
exportMetadata.tableCount = tablesToExport.length;
|
||||||
|
exportMetadata.recordCount = totalRecords;
|
||||||
|
|
||||||
|
const insertMetadata = exportDb.prepare(`INSERT INTO ${this.METADATA_TABLE} (key, value) VALUES (?, ?)`);
|
||||||
|
insertMetadata.run('metadata', JSON.stringify(exportMetadata));
|
||||||
|
|
||||||
|
// Close export database
|
||||||
|
exportDb.close();
|
||||||
|
|
||||||
|
databaseLogger.success('SQLite database export completed successfully', {
|
||||||
|
operation: 'database_sqlite_export_complete',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath,
|
||||||
|
tableCount: exportMetadata.tableCount,
|
||||||
|
recordCount: exportMetadata.recordCount,
|
||||||
|
fileSize: fs.statSync(actualExportPath).size
|
||||||
|
});
|
||||||
|
|
||||||
|
return actualExportPath;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('SQLite database export failed', error, {
|
||||||
|
operation: 'database_sqlite_export_failed',
|
||||||
|
exportId,
|
||||||
|
exportPath: actualExportPath
|
||||||
|
});
|
||||||
|
throw new Error(`SQLite database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import database from SQLite export
|
||||||
|
* Re-encrypts fields for the current hardware
|
||||||
|
*/
|
||||||
|
static async importDatabase(importPath: string, options: {
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
backupCurrent?: boolean;
|
||||||
|
} = {}): Promise<ImportResult> {
|
||||||
|
const { replaceExisting = false, backupCurrent = true } = options;
|
||||||
|
|
||||||
|
if (!fs.existsSync(importPath)) {
|
||||||
|
throw new Error(`Import file does not exist: ${importPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.info('Starting SQLite database import from export', {
|
||||||
|
operation: 'database_sqlite_import',
|
||||||
|
importPath,
|
||||||
|
replaceExisting,
|
||||||
|
backupCurrent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open import database
|
||||||
|
const importDb = new Database(importPath, { readonly: true });
|
||||||
|
|
||||||
|
// Validate export format
|
||||||
|
const metadataResult = importDb.prepare(`
|
||||||
|
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
|
||||||
|
`).get() as { value: string } | undefined;
|
||||||
|
|
||||||
|
if (!metadataResult) {
|
||||||
|
throw new Error('Invalid export file: missing metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: ExportMetadata = JSON.parse(metadataResult.value);
|
||||||
|
if (metadata.version !== this.VERSION) {
|
||||||
|
throw new Error(`Unsupported export version: ${metadata.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
success: false,
|
||||||
|
imported: { tables: 0, records: 0 },
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current admin user to assign imported SSH records
|
||||||
|
const adminUser = await db.select().from(users).where(eq(users.is_admin, true)).limit(1);
|
||||||
|
if (adminUser.length === 0) {
|
||||||
|
throw new Error('No admin user found in current database');
|
||||||
|
}
|
||||||
|
const currentAdminUserId = adminUser[0].id;
|
||||||
|
|
||||||
|
databaseLogger.debug(`Starting SSH data import - assigning to admin user ${currentAdminUserId}`, {
|
||||||
|
operation: 'ssh_data_import_start',
|
||||||
|
adminUserId: currentAdminUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create backup if requested
|
||||||
|
if (backupCurrent) {
|
||||||
|
try {
|
||||||
|
const backupPath = await this.createCurrentDatabaseBackup();
|
||||||
|
databaseLogger.info('Current database backed up before import', {
|
||||||
|
operation: 'import_backup',
|
||||||
|
backupPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.warnings.push(warningMsg);
|
||||||
|
databaseLogger.warn('Failed to create pre-import backup', {
|
||||||
|
operation: 'import_backup_failed',
|
||||||
|
error: warningMsg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of tables to import (excluding metadata table)
|
||||||
|
const tables = importDb.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name != '${this.METADATA_TABLE}'
|
||||||
|
`).all() as { name: string }[];
|
||||||
|
|
||||||
|
// Import data table by table
|
||||||
|
for (const tableRow of tables) {
|
||||||
|
const tableName = tableRow.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.debug(`Importing SQLite table: ${tableName}`, {
|
||||||
|
operation: 'table_sqlite_import',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use additive import - don't clear existing data
|
||||||
|
// This preserves all current data including admin SSH connections
|
||||||
|
databaseLogger.debug(`Using additive import for ${tableName}`, {
|
||||||
|
operation: 'table_additive_import',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all records from import table
|
||||||
|
const records = importDb.prepare(`SELECT * FROM ${tableName}`).all();
|
||||||
|
|
||||||
|
// Process and encrypt records
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
// Import all SSH data without user filtering
|
||||||
|
|
||||||
|
// Map database column names to TypeScript field names
|
||||||
|
const mappedRecord: any = {};
|
||||||
|
const columnToFieldMappings: Record<string, string> = {
|
||||||
|
'user_id': 'userId',
|
||||||
|
'auth_type': 'authType',
|
||||||
|
'require_password': 'requirePassword',
|
||||||
|
'key_password': 'keyPassword',
|
||||||
|
'key_type': 'keyType',
|
||||||
|
'credential_id': 'credentialId',
|
||||||
|
'enable_terminal': 'enableTerminal',
|
||||||
|
'enable_tunnel': 'enableTunnel',
|
||||||
|
'tunnel_connections': 'tunnelConnections',
|
||||||
|
'enable_file_manager': 'enableFileManager',
|
||||||
|
'default_path': 'defaultPath',
|
||||||
|
'created_at': 'createdAt',
|
||||||
|
'updated_at': 'updatedAt',
|
||||||
|
'key_content': 'keyContent'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert database column names to TypeScript field names
|
||||||
|
for (const [dbColumn, value] of Object.entries(record)) {
|
||||||
|
const tsField = columnToFieldMappings[dbColumn] || dbColumn;
|
||||||
|
mappedRecord[tsField] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign imported SSH records to current admin user to avoid foreign key constraint
|
||||||
|
if (tableName === 'ssh_data' && mappedRecord.userId) {
|
||||||
|
const originalUserId = mappedRecord.userId;
|
||||||
|
mappedRecord.userId = currentAdminUserId;
|
||||||
|
databaseLogger.debug(`Reassigned SSH record from user ${originalUserId} to admin ${currentAdminUserId}`, {
|
||||||
|
operation: 'user_reassignment',
|
||||||
|
originalUserId,
|
||||||
|
newUserId: currentAdminUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encrypt sensitive fields for current hardware
|
||||||
|
const processedRecord = DatabaseEncryption.encryptRecord(tableName, mappedRecord);
|
||||||
|
|
||||||
|
// Insert record using Drizzle
|
||||||
|
try {
|
||||||
|
if (tableName === 'ssh_data') {
|
||||||
|
await db.insert(sshData).values(processedRecord).onConflictDoNothing();
|
||||||
|
} else if (tableName === 'ssh_credentials') {
|
||||||
|
await db.insert(sshCredentials).values(processedRecord).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle any SQL errors gracefully
|
||||||
|
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||||||
|
databaseLogger.debug(`Skipping duplicate record in ${tableName}`, {
|
||||||
|
operation: 'duplicate_record_skip',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.errors.push(errorMsg);
|
||||||
|
databaseLogger.error('Failed to import record', error, {
|
||||||
|
operation: 'record_sqlite_import_failed',
|
||||||
|
table: tableName,
|
||||||
|
recordId: (record as any).id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.imported.tables++;
|
||||||
|
result.imported.records += records.length;
|
||||||
|
|
||||||
|
databaseLogger.debug(`SQLite table ${tableName} imported`, {
|
||||||
|
operation: 'table_sqlite_import_complete',
|
||||||
|
table: tableName,
|
||||||
|
recordCount: records.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
result.errors.push(errorMsg);
|
||||||
|
databaseLogger.error('Failed to import SQLite table', error, {
|
||||||
|
operation: 'table_sqlite_import_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close import database
|
||||||
|
importDb.close();
|
||||||
|
|
||||||
|
// Check if import was successful
|
||||||
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
databaseLogger.success('SQLite database import completed successfully', {
|
||||||
|
operation: 'database_sqlite_import_complete',
|
||||||
|
importPath,
|
||||||
|
tablesImported: result.imported.tables,
|
||||||
|
recordsImported: result.imported.records,
|
||||||
|
warnings: result.warnings.length
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
databaseLogger.error('SQLite database import completed with errors', undefined, {
|
||||||
|
operation: 'database_sqlite_import_partial',
|
||||||
|
importPath,
|
||||||
|
tablesImported: result.imported.tables,
|
||||||
|
recordsImported: result.imported.records,
|
||||||
|
errorCount: result.errors.length,
|
||||||
|
warningCount: result.warnings.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('SQLite database import failed', error, {
|
||||||
|
operation: 'database_sqlite_import_failed',
|
||||||
|
importPath
|
||||||
|
});
|
||||||
|
throw new Error(`SQLite database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate SQLite export file
|
||||||
|
*/
|
||||||
|
static validateExportFile(exportPath: string): {
|
||||||
|
valid: boolean;
|
||||||
|
metadata?: ExportMetadata;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const result = {
|
||||||
|
valid: false,
|
||||||
|
metadata: undefined as ExportMetadata | undefined,
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(exportPath)) {
|
||||||
|
result.errors.push('Export file does not exist');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exportPath.endsWith(this.EXPORT_FILE_EXTENSION)) {
|
||||||
|
result.errors.push('Invalid export file extension');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportDb = new Database(exportPath, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadataResult = exportDb.prepare(`
|
||||||
|
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
|
||||||
|
`).get() as { value: string } | undefined;
|
||||||
|
|
||||||
|
if (!metadataResult) {
|
||||||
|
result.errors.push('Missing export metadata');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: ExportMetadata = JSON.parse(metadataResult.value);
|
||||||
|
|
||||||
|
if (metadata.version !== this.VERSION) {
|
||||||
|
result.errors.push(`Unsupported export version: ${metadata.version}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.valid = true;
|
||||||
|
result.metadata = metadata;
|
||||||
|
} finally {
|
||||||
|
exportDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Failed to validate export file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export file info without importing
|
||||||
|
*/
|
||||||
|
static getExportInfo(exportPath: string): ExportMetadata | null {
|
||||||
|
const validation = this.validateExportFile(exportPath);
|
||||||
|
return validation.valid ? validation.metadata! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backup of current database
|
||||||
|
*/
|
||||||
|
private static async createCurrentDatabaseBackup(): Promise<string> {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupDir = path.join(databasePaths.directory, 'backups');
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SQLite backup
|
||||||
|
const backupPath = path.join(backupDir, `database-backup-${timestamp}.sqlite`);
|
||||||
|
|
||||||
|
// Copy current database file
|
||||||
|
fs.copyFileSync(databasePaths.main, backupPath);
|
||||||
|
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get table schema for database operations
|
||||||
|
* NOTE: This method is deprecated - we now use raw SQL to avoid FK issues
|
||||||
|
*/
|
||||||
|
private static getTableSchema(tableName: string) {
|
||||||
|
return null; // No longer used
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field should be tracked as encrypted
|
||||||
|
*/
|
||||||
|
private static shouldTrackEncryptedField(tableName: string, fieldName: string): boolean {
|
||||||
|
try {
|
||||||
|
return FieldEncryption.shouldEncryptField(tableName, fieldName);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DatabaseSQLiteExport };
|
||||||
|
export type { ExportMetadata, ImportResult };
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { db } from '../database/db/index.js';
|
||||||
|
import { DatabaseEncryption } from './database-encryption.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
type TableName = 'users' | 'ssh_data' | 'ssh_credentials';
|
||||||
|
|
||||||
|
class EncryptedDBOperations {
|
||||||
|
static async insert<T extends Record<string, any>>(
|
||||||
|
table: SQLiteTable<any>,
|
||||||
|
tableName: TableName,
|
||||||
|
data: T
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
|
||||||
|
const result = await db.insert(table).values(encryptedData).returning();
|
||||||
|
|
||||||
|
// Decrypt the returned data to ensure consistency
|
||||||
|
const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result[0]);
|
||||||
|
|
||||||
|
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
|
||||||
|
operation: 'encrypted_insert',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedResult as T;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to insert encrypted record into ${tableName}`, error, {
|
||||||
|
operation: 'encrypted_insert_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async select<T extends Record<string, any>>(
|
||||||
|
query: any,
|
||||||
|
tableName: TableName
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const results = await query;
|
||||||
|
const decryptedResults = DatabaseEncryption.decryptRecords(tableName, results);
|
||||||
|
|
||||||
|
databaseLogger.debug(`Selected and decrypted ${decryptedResults.length} records from ${tableName}`, {
|
||||||
|
operation: 'encrypted_select',
|
||||||
|
table: tableName,
|
||||||
|
count: decryptedResults.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedResults;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to select/decrypt records from ${tableName}`, error, {
|
||||||
|
operation: 'encrypted_select_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async selectOne<T extends Record<string, any>>(
|
||||||
|
query: any,
|
||||||
|
tableName: TableName
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const result = await query;
|
||||||
|
if (!result) return undefined;
|
||||||
|
|
||||||
|
const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result);
|
||||||
|
|
||||||
|
databaseLogger.debug(`Selected and decrypted single record from ${tableName}`, {
|
||||||
|
operation: 'encrypted_select_one',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedResult;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to select/decrypt single record from ${tableName}`, error, {
|
||||||
|
operation: 'encrypted_select_one_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update<T extends Record<string, any>>(
|
||||||
|
table: SQLiteTable<any>,
|
||||||
|
tableName: TableName,
|
||||||
|
where: any,
|
||||||
|
data: Partial<T>
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
|
||||||
|
const result = await db.update(table).set(encryptedData).where(where).returning();
|
||||||
|
|
||||||
|
databaseLogger.debug(`Updated encrypted record in ${tableName}`, {
|
||||||
|
operation: 'encrypted_update',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T[];
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to update encrypted record in ${tableName}`, error, {
|
||||||
|
operation: 'encrypted_update_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(
|
||||||
|
table: SQLiteTable<any>,
|
||||||
|
tableName: TableName,
|
||||||
|
where: any
|
||||||
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const result = await db.delete(table).where(where).returning();
|
||||||
|
|
||||||
|
databaseLogger.debug(`Deleted record from ${tableName}`, {
|
||||||
|
operation: 'encrypted_delete',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to delete record from ${tableName}`, error, {
|
||||||
|
operation: 'encrypted_delete_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async migrateExistingRecords(tableName: TableName): Promise<number> {
|
||||||
|
let migratedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
databaseLogger.info(`Starting encryption migration for ${tableName}`, {
|
||||||
|
operation: 'migration_start',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
let table: SQLiteTable<any>;
|
||||||
|
let records: any[];
|
||||||
|
|
||||||
|
switch (tableName) {
|
||||||
|
case 'users':
|
||||||
|
const { users } = await import('../database/db/schema.js');
|
||||||
|
table = users;
|
||||||
|
records = await db.select().from(users);
|
||||||
|
break;
|
||||||
|
case 'ssh_data':
|
||||||
|
const { sshData } = await import('../database/db/schema.js');
|
||||||
|
table = sshData;
|
||||||
|
records = await db.select().from(sshData);
|
||||||
|
break;
|
||||||
|
case 'ssh_credentials':
|
||||||
|
const { sshCredentials } = await import('../database/db/schema.js');
|
||||||
|
table = sshCredentials;
|
||||||
|
records = await db.select().from(sshCredentials);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown table: ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const migratedRecord = await DatabaseEncryption.migrateRecord(tableName, record);
|
||||||
|
|
||||||
|
if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) {
|
||||||
|
const { eq } = await import('drizzle-orm');
|
||||||
|
await db.update(table).set(migratedRecord).where(eq((table as any).id, record.id));
|
||||||
|
migratedCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to migrate record ${record.id} in ${tableName}`, error, {
|
||||||
|
operation: 'migration_record_failed',
|
||||||
|
table: tableName,
|
||||||
|
recordId: record.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success(`Migration completed for ${tableName}`, {
|
||||||
|
operation: 'migration_complete',
|
||||||
|
table: tableName,
|
||||||
|
migratedCount,
|
||||||
|
totalRecords: records.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return migratedCount;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Migration failed for ${tableName}`, error, {
|
||||||
|
operation: 'migration_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const status = DatabaseEncryption.getEncryptionStatus();
|
||||||
|
return status.configValid && status.enabled;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Encryption health check failed', error, {
|
||||||
|
operation: 'health_check_failed'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EncryptedDBOperations };
|
||||||
|
export type { TableName };
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { db } from '../database/db/index.js';
|
||||||
|
import { settings } from '../database/db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import { MasterKeyProtection } from './master-key-protection.js';
|
||||||
|
|
||||||
|
interface EncryptionKeyInfo {
|
||||||
|
hasKey: boolean;
|
||||||
|
keyId?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
algorithm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptionKeyManager {
|
||||||
|
private static instance: EncryptionKeyManager;
|
||||||
|
private currentKey: string | null = null;
|
||||||
|
private keyInfo: EncryptionKeyInfo | null = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): EncryptionKeyManager {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new EncryptionKeyManager();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeKey(key: string): string {
|
||||||
|
return MasterKeyProtection.encryptMasterKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeKey(encodedKey: string): string {
|
||||||
|
if (MasterKeyProtection.isProtectedKey(encodedKey)) {
|
||||||
|
return MasterKeyProtection.decryptMasterKey(encodedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.warn('Found legacy base64-encoded key, migrating to KEK protection', {
|
||||||
|
operation: 'key_migration_legacy'
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(encodedKey, 'base64');
|
||||||
|
return buffer.toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeKey(): Promise<string> {
|
||||||
|
databaseLogger.info('Initializing encryption key system...', {
|
||||||
|
operation: 'key_init'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let existingKey = await this.getStoredKey();
|
||||||
|
|
||||||
|
if (existingKey) {
|
||||||
|
databaseLogger.success('Found existing encryption key', {
|
||||||
|
operation: 'key_init',
|
||||||
|
hasKey: true
|
||||||
|
});
|
||||||
|
this.currentKey = existingKey;
|
||||||
|
return existingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environmentKey = process.env.DB_ENCRYPTION_KEY;
|
||||||
|
if (environmentKey && environmentKey !== 'default-key-change-me') {
|
||||||
|
if (!this.validateKeyStrength(environmentKey)) {
|
||||||
|
databaseLogger.error('Environment encryption key is too weak', undefined, {
|
||||||
|
operation: 'key_init',
|
||||||
|
source: 'environment',
|
||||||
|
keyLength: environmentKey.length
|
||||||
|
});
|
||||||
|
throw new Error('DB_ENCRYPTION_KEY is too weak. Must be at least 32 characters with good entropy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.info('Using encryption key from environment variable', {
|
||||||
|
operation: 'key_init',
|
||||||
|
source: 'environment'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.storeKey(environmentKey);
|
||||||
|
this.currentKey = environmentKey;
|
||||||
|
return environmentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKey = await this.generateNewKey();
|
||||||
|
databaseLogger.warn('Generated new encryption key - PLEASE BACKUP THIS KEY', {
|
||||||
|
operation: 'key_init',
|
||||||
|
generated: true,
|
||||||
|
keyPreview: newKey.substring(0, 8) + '...'
|
||||||
|
});
|
||||||
|
|
||||||
|
return newKey;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to initialize encryption key', error, {
|
||||||
|
operation: 'key_init_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateNewKey(): Promise<string> {
|
||||||
|
const newKey = crypto.randomBytes(32).toString('hex');
|
||||||
|
const keyId = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
await this.storeKey(newKey, keyId);
|
||||||
|
this.currentKey = newKey;
|
||||||
|
|
||||||
|
databaseLogger.success('Generated new encryption key', {
|
||||||
|
operation: 'key_generated',
|
||||||
|
keyId,
|
||||||
|
keyLength: newKey.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeKey(key: string, keyId?: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = keyId || crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
const keyData = {
|
||||||
|
key: this.encodeKey(key),
|
||||||
|
keyId: id,
|
||||||
|
createdAt: now,
|
||||||
|
algorithm: 'aes-256-gcm'
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedData = JSON.stringify(keyData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: encodedData })
|
||||||
|
.where(eq(settings.key, 'db_encryption_key'));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({
|
||||||
|
key: 'db_encryption_key',
|
||||||
|
value: encodedData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCreated = await db.select().from(settings).where(eq(settings.key, 'encryption_key_created'));
|
||||||
|
|
||||||
|
if (existingCreated.length > 0) {
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: now })
|
||||||
|
.where(eq(settings.key, 'encryption_key_created'));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({
|
||||||
|
key: 'encryption_key_created',
|
||||||
|
value: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keyInfo = {
|
||||||
|
hasKey: true,
|
||||||
|
keyId: id,
|
||||||
|
createdAt: now,
|
||||||
|
algorithm: 'aes-256-gcm'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to store encryption key', error, {
|
||||||
|
operation: 'key_store_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStoredKey(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedData = result[0].value;
|
||||||
|
let keyData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
keyData = JSON.parse(encodedData);
|
||||||
|
} catch {
|
||||||
|
databaseLogger.warn('Found legacy base64-encoded key data, migrating', {
|
||||||
|
operation: 'key_data_migration_legacy'
|
||||||
|
});
|
||||||
|
keyData = JSON.parse(Buffer.from(encodedData, 'base64').toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keyInfo = {
|
||||||
|
hasKey: true,
|
||||||
|
keyId: keyData.keyId,
|
||||||
|
createdAt: keyData.createdAt,
|
||||||
|
algorithm: keyData.algorithm
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodedKey = this.decodeKey(keyData.key);
|
||||||
|
|
||||||
|
if (!MasterKeyProtection.isProtectedKey(keyData.key)) {
|
||||||
|
databaseLogger.info('Auto-migrating legacy key to KEK protection', {
|
||||||
|
operation: 'key_auto_migration',
|
||||||
|
keyId: keyData.keyId
|
||||||
|
});
|
||||||
|
await this.storeKey(decodedKey, keyData.keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedKey;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to retrieve stored encryption key', error, {
|
||||||
|
operation: 'key_retrieve_failed'
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentKey(): string | null {
|
||||||
|
return this.currentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKeyInfo(): Promise<EncryptionKeyInfo> {
|
||||||
|
if (!this.keyInfo) {
|
||||||
|
const hasKey = await this.getStoredKey() !== null;
|
||||||
|
return {
|
||||||
|
hasKey,
|
||||||
|
algorithm: 'aes-256-gcm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.keyInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerateKey(): Promise<string> {
|
||||||
|
databaseLogger.info('Regenerating encryption key', {
|
||||||
|
operation: 'key_regenerate'
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldKeyInfo = await this.getKeyInfo();
|
||||||
|
const newKey = await this.generateNewKey();
|
||||||
|
|
||||||
|
databaseLogger.warn('Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED', {
|
||||||
|
operation: 'key_regenerated',
|
||||||
|
oldKeyId: oldKeyInfo.keyId,
|
||||||
|
newKeyId: this.keyInfo?.keyId
|
||||||
|
});
|
||||||
|
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateKeyStrength(key: string): boolean {
|
||||||
|
if (key.length < 32) return false;
|
||||||
|
|
||||||
|
const hasLower = /[a-z]/.test(key);
|
||||||
|
const hasUpper = /[A-Z]/.test(key);
|
||||||
|
const hasDigit = /\d/.test(key);
|
||||||
|
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(key);
|
||||||
|
|
||||||
|
const entropyTest = new Set(key).size / key.length;
|
||||||
|
|
||||||
|
const complexity = Number(hasLower) + Number(hasUpper) + Number(hasDigit) + Number(hasSpecial);
|
||||||
|
return complexity >= 3 && entropyTest > 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateKey(key?: string): Promise<boolean> {
|
||||||
|
const testKey = key || this.currentKey;
|
||||||
|
if (!testKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testData = 'validation-test-' + Date.now();
|
||||||
|
const testBuffer = Buffer.from(testKey, 'hex');
|
||||||
|
|
||||||
|
if (testBuffer.length !== 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', testBuffer, iv) as any;
|
||||||
|
cipher.update(testData, 'utf8');
|
||||||
|
cipher.final();
|
||||||
|
cipher.getAuthTag();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.currentKey !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEncryptionStatus() {
|
||||||
|
const keyInfo = await this.getKeyInfo();
|
||||||
|
const isValid = await this.validateKey();
|
||||||
|
const kekProtected = await this.isKEKProtected();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasKey: keyInfo.hasKey,
|
||||||
|
keyValid: isValid,
|
||||||
|
keyId: keyInfo.keyId,
|
||||||
|
createdAt: keyInfo.createdAt,
|
||||||
|
algorithm: keyInfo.algorithm,
|
||||||
|
initialized: this.isInitialized(),
|
||||||
|
kekProtected,
|
||||||
|
kekValid: kekProtected ? MasterKeyProtection.validateProtection() : false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isKEKProtected(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
|
||||||
|
if (result.length === 0) return false;
|
||||||
|
|
||||||
|
const keyData = JSON.parse(result[0].value);
|
||||||
|
return MasterKeyProtection.isProtectedKey(keyData.key);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EncryptionKeyManager };
|
||||||
|
export type { EncryptionKeyInfo };
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { DatabaseEncryption } from './database-encryption.js';
|
||||||
|
import { EncryptedDBOperations } from './encrypted-db-operations.js';
|
||||||
|
import { EncryptionKeyManager } from './encryption-key-manager.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import { db } from '../database/db/index.js';
|
||||||
|
import { settings } from '../database/db/schema.js';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
interface MigrationConfig {
|
||||||
|
masterPassword?: string;
|
||||||
|
forceEncryption?: boolean;
|
||||||
|
backupEnabled?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptionMigration {
|
||||||
|
private config: MigrationConfig;
|
||||||
|
|
||||||
|
constructor(config: MigrationConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
masterPassword: config.masterPassword,
|
||||||
|
forceEncryption: config.forceEncryption ?? false,
|
||||||
|
backupEnabled: config.backupEnabled ?? true,
|
||||||
|
dryRun: config.dryRun ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMigration(): Promise<void> {
|
||||||
|
databaseLogger.info('Starting database encryption migration', {
|
||||||
|
operation: 'migration_start',
|
||||||
|
dryRun: this.config.dryRun,
|
||||||
|
forceEncryption: this.config.forceEncryption
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.validatePrerequisites();
|
||||||
|
|
||||||
|
if (this.config.backupEnabled && !this.config.dryRun) {
|
||||||
|
await this.createBackup();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializeEncryption();
|
||||||
|
await this.migrateTables();
|
||||||
|
await this.updateSettings();
|
||||||
|
await this.verifyMigration();
|
||||||
|
|
||||||
|
databaseLogger.success('Database encryption migration completed successfully', {
|
||||||
|
operation: 'migration_complete'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Migration failed', error, {
|
||||||
|
operation: 'migration_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validatePrerequisites(): Promise<void> {
|
||||||
|
databaseLogger.info('Validating migration prerequisites', {
|
||||||
|
operation: 'validation'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if KEK-managed encryption key exists
|
||||||
|
const keyManager = EncryptionKeyManager.getInstance();
|
||||||
|
|
||||||
|
if (!this.config.masterPassword) {
|
||||||
|
// Try to get current key from KEK manager
|
||||||
|
try {
|
||||||
|
const currentKey = keyManager.getCurrentKey();
|
||||||
|
if (!currentKey) {
|
||||||
|
// Initialize key if not available
|
||||||
|
const initializedKey = await keyManager.initializeKey();
|
||||||
|
this.config.masterPassword = initializedKey;
|
||||||
|
} else {
|
||||||
|
this.config.masterPassword = currentKey;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key strength
|
||||||
|
if (this.config.masterPassword.length < 16) {
|
||||||
|
throw new Error('Master password must be at least 16 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
try {
|
||||||
|
await db.select().from(settings).limit(1);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Database connection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success('Prerequisites validation passed', {
|
||||||
|
operation: 'validation_complete',
|
||||||
|
keySource: 'kek_manager'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBackup(): Promise<void> {
|
||||||
|
databaseLogger.info('Creating database backup before migration', {
|
||||||
|
operation: 'backup_start'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const dataDir = process.env.DATA_DIR || './db/data';
|
||||||
|
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||||
|
const backupPath = path.join(dataDir, `db-backup-${Date.now()}.sqlite`);
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
databaseLogger.success(`Database backup created: ${backupPath}`, {
|
||||||
|
operation: 'backup_complete',
|
||||||
|
backupPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to create backup', error, {
|
||||||
|
operation: 'backup_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeEncryption(): Promise<void> {
|
||||||
|
databaseLogger.info('Initializing encryption system', {
|
||||||
|
operation: 'encryption_init'
|
||||||
|
});
|
||||||
|
|
||||||
|
DatabaseEncryption.initialize({
|
||||||
|
masterPassword: this.config.masterPassword!,
|
||||||
|
encryptionEnabled: true,
|
||||||
|
forceEncryption: this.config.forceEncryption,
|
||||||
|
migrateOnAccess: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHealthy = await EncryptedDBOperations.healthCheck();
|
||||||
|
if (!isHealthy) {
|
||||||
|
throw new Error('Encryption system health check failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success('Encryption system initialized successfully', {
|
||||||
|
operation: 'encryption_init_complete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateTables(): Promise<void> {
|
||||||
|
const tables: Array<'users' | 'ssh_data' | 'ssh_credentials'> = [
|
||||||
|
'users',
|
||||||
|
'ssh_data',
|
||||||
|
'ssh_credentials'
|
||||||
|
];
|
||||||
|
|
||||||
|
let totalMigrated = 0;
|
||||||
|
|
||||||
|
for (const tableName of tables) {
|
||||||
|
databaseLogger.info(`Starting migration for table: ${tableName}`, {
|
||||||
|
operation: 'table_migration_start',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.config.dryRun) {
|
||||||
|
databaseLogger.info(`[DRY RUN] Would migrate table: ${tableName}`, {
|
||||||
|
operation: 'dry_run_table',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migratedCount = await EncryptedDBOperations.migrateExistingRecords(tableName);
|
||||||
|
totalMigrated += migratedCount;
|
||||||
|
|
||||||
|
databaseLogger.success(`Migration completed for table: ${tableName}`, {
|
||||||
|
operation: 'table_migration_complete',
|
||||||
|
table: tableName,
|
||||||
|
migratedCount
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Migration failed for table: ${tableName}`, error, {
|
||||||
|
operation: 'table_migration_failed',
|
||||||
|
table: tableName
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success(`All tables migrated successfully`, {
|
||||||
|
operation: 'all_tables_migrated',
|
||||||
|
totalMigrated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateSettings(): Promise<void> {
|
||||||
|
if (this.config.dryRun) {
|
||||||
|
databaseLogger.info('[DRY RUN] Would update encryption settings', {
|
||||||
|
operation: 'dry_run_settings'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptionSettings = [
|
||||||
|
{ key: 'encryption_enabled', value: 'true' },
|
||||||
|
{ key: 'encryption_migration_completed', value: new Date().toISOString() },
|
||||||
|
{ key: 'encryption_version', value: '1.0' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const setting of encryptionSettings) {
|
||||||
|
const existing = await db.select().from(settings).where(eq(settings.key, setting.key));
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(settings).set({ value: setting.value }).where(eq(settings.key, setting.key));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success('Encryption settings updated', {
|
||||||
|
operation: 'settings_updated'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to update settings', error, {
|
||||||
|
operation: 'settings_update_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyMigration(): Promise<void> {
|
||||||
|
databaseLogger.info('Verifying migration integrity', {
|
||||||
|
operation: 'verification_start'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = DatabaseEncryption.getEncryptionStatus();
|
||||||
|
|
||||||
|
if (!status.enabled || !status.configValid) {
|
||||||
|
throw new Error('Encryption system verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const testResult = await this.performTestEncryption();
|
||||||
|
if (!testResult) {
|
||||||
|
throw new Error('Test encryption/decryption failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.success('Migration verification completed successfully', {
|
||||||
|
operation: 'verification_complete',
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Migration verification failed', error, {
|
||||||
|
operation: 'verification_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performTestEncryption(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { FieldEncryption } = await import('./encryption.js');
|
||||||
|
const testData = `test-data-${Date.now()}`;
|
||||||
|
const testKey = FieldEncryption.getFieldKey(this.config.masterPassword!, 'test');
|
||||||
|
|
||||||
|
const encrypted = FieldEncryption.encryptField(testData, testKey);
|
||||||
|
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
|
||||||
|
|
||||||
|
return decrypted === testData;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkMigrationStatus(): Promise<{
|
||||||
|
isEncryptionEnabled: boolean;
|
||||||
|
migrationCompleted: boolean;
|
||||||
|
migrationRequired: boolean;
|
||||||
|
migrationDate?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const encryptionEnabled = await db.select().from(settings).where(eq(settings.key, 'encryption_enabled'));
|
||||||
|
const migrationCompleted = await db.select().from(settings).where(eq(settings.key, 'encryption_migration_completed'));
|
||||||
|
|
||||||
|
const isEncryptionEnabled = encryptionEnabled.length > 0 && encryptionEnabled[0].value === 'true';
|
||||||
|
const isMigrationCompleted = migrationCompleted.length > 0;
|
||||||
|
|
||||||
|
// Check if migration is actually required by looking for unencrypted sensitive data
|
||||||
|
const migrationRequired = await this.checkIfMigrationRequired();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEncryptionEnabled,
|
||||||
|
migrationCompleted: isMigrationCompleted,
|
||||||
|
migrationRequired,
|
||||||
|
migrationDate: isMigrationCompleted ? migrationCompleted[0].value : undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to check migration status', error, {
|
||||||
|
operation: 'status_check_failed'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkIfMigrationRequired(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Import table schemas
|
||||||
|
const { sshData, sshCredentials } = await import('../database/db/schema.js');
|
||||||
|
|
||||||
|
// Check if there's any unencrypted sensitive data in ssh_data
|
||||||
|
const sshDataCount = await db.select({ count: sql<number>`count(*)` }).from(sshData);
|
||||||
|
if (sshDataCount[0].count > 0) {
|
||||||
|
// Sample a few records to check if they contain unencrypted data
|
||||||
|
const sampleData = await db.select().from(sshData).limit(5);
|
||||||
|
for (const record of sampleData) {
|
||||||
|
if (record.password && !this.looksEncrypted(record.password)) {
|
||||||
|
return true; // Found unencrypted password
|
||||||
|
}
|
||||||
|
if (record.key && !this.looksEncrypted(record.key)) {
|
||||||
|
return true; // Found unencrypted key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's any unencrypted sensitive data in ssh_credentials
|
||||||
|
const credentialsCount = await db.select({ count: sql<number>`count(*)` }).from(sshCredentials);
|
||||||
|
if (credentialsCount[0].count > 0) {
|
||||||
|
const sampleCredentials = await db.select().from(sshCredentials).limit(5);
|
||||||
|
for (const record of sampleCredentials) {
|
||||||
|
if (record.password && !this.looksEncrypted(record.password)) {
|
||||||
|
return true; // Found unencrypted password
|
||||||
|
}
|
||||||
|
if (record.privateKey && !this.looksEncrypted(record.privateKey)) {
|
||||||
|
return true; // Found unencrypted private key
|
||||||
|
}
|
||||||
|
if (record.keyPassword && !this.looksEncrypted(record.keyPassword)) {
|
||||||
|
return true; // Found unencrypted key password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // No unencrypted sensitive data found
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn('Failed to check if migration required, assuming required', {
|
||||||
|
operation: 'migration_check_failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
return true; // If we can't check, assume migration is required for safety
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static looksEncrypted(data: string): boolean {
|
||||||
|
if (!data) return true; // Empty data doesn't need encryption
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it looks like our encrypted format: {"data":"...","iv":"...","tag":"..."}
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return !!(parsed.data && parsed.iv && parsed.tag);
|
||||||
|
} catch {
|
||||||
|
// If it's not JSON, check if it's a reasonable length for encrypted data
|
||||||
|
// Encrypted data is typically much longer than plaintext
|
||||||
|
return data.length > 100 && data.includes('='); // Base64-like characteristics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const config: MigrationConfig = {
|
||||||
|
masterPassword: process.env.DB_ENCRYPTION_KEY,
|
||||||
|
forceEncryption: process.env.FORCE_ENCRYPTION === 'true',
|
||||||
|
backupEnabled: process.env.BACKUP_ENABLED !== 'false',
|
||||||
|
dryRun: process.env.DRY_RUN === 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
const migration = new EncryptionMigration(config);
|
||||||
|
|
||||||
|
migration.runMigration()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Migration failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EncryptionMigration };
|
||||||
|
export type { MigrationConfig };
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { FieldEncryption } from './encryption.js';
|
||||||
|
import { DatabaseEncryption } from './database-encryption.js';
|
||||||
|
import { EncryptedDBOperations } from './encrypted-db-operations.js';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
|
||||||
|
class EncryptionTest {
|
||||||
|
private testPassword = 'test-master-password-for-validation';
|
||||||
|
|
||||||
|
async runAllTests(): Promise<boolean> {
|
||||||
|
console.log('🔐 Starting Termix Database Encryption Tests...\n');
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{ name: 'Basic Encryption/Decryption', test: () => this.testBasicEncryption() },
|
||||||
|
{ name: 'Field Encryption Detection', test: () => this.testFieldDetection() },
|
||||||
|
{ name: 'Key Derivation', test: () => this.testKeyDerivation() },
|
||||||
|
{ name: 'Database Encryption Context', test: () => this.testDatabaseContext() },
|
||||||
|
{ name: 'Record Encryption/Decryption', test: () => this.testRecordOperations() },
|
||||||
|
{ name: 'Backward Compatibility', test: () => this.testBackwardCompatibility() },
|
||||||
|
{ name: 'Error Handling', test: () => this.testErrorHandling() },
|
||||||
|
{ name: 'Performance Test', test: () => this.testPerformance() }
|
||||||
|
];
|
||||||
|
|
||||||
|
let passedTests = 0;
|
||||||
|
let totalTests = tests.length;
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
try {
|
||||||
|
console.log(`⏳ Running: ${test.name}...`);
|
||||||
|
await test.test();
|
||||||
|
console.log(`✅ PASSED: ${test.name}\n`);
|
||||||
|
passedTests++;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ FAILED: ${test.name}`);
|
||||||
|
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = passedTests === totalTests;
|
||||||
|
console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('🎉 All encryption tests PASSED! System is ready for production.');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Some tests FAILED! Please review the implementation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testBasicEncryption(): Promise<void> {
|
||||||
|
const testData = 'Hello, World! This is sensitive data.';
|
||||||
|
const key = FieldEncryption.getFieldKey(this.testPassword, 'test-field');
|
||||||
|
|
||||||
|
const encrypted = FieldEncryption.encryptField(testData, key);
|
||||||
|
const decrypted = FieldEncryption.decryptField(encrypted, key);
|
||||||
|
|
||||||
|
if (decrypted !== testData) {
|
||||||
|
throw new Error(`Decryption mismatch: expected "${testData}", got "${decrypted}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FieldEncryption.isEncrypted(encrypted)) {
|
||||||
|
throw new Error('Encrypted data not detected as encrypted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FieldEncryption.isEncrypted(testData)) {
|
||||||
|
throw new Error('Plain text incorrectly detected as encrypted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testFieldDetection(): Promise<void> {
|
||||||
|
const testCases = [
|
||||||
|
{ table: 'users', field: 'password_hash', shouldEncrypt: true },
|
||||||
|
{ table: 'users', field: 'username', shouldEncrypt: false },
|
||||||
|
{ table: 'ssh_data', field: 'password', shouldEncrypt: true },
|
||||||
|
{ table: 'ssh_data', field: 'ip', shouldEncrypt: false },
|
||||||
|
{ table: 'ssh_credentials', field: 'privateKey', shouldEncrypt: true },
|
||||||
|
{ table: 'unknown_table', field: 'any_field', shouldEncrypt: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const result = FieldEncryption.shouldEncryptField(testCase.table, testCase.field);
|
||||||
|
if (result !== testCase.shouldEncrypt) {
|
||||||
|
throw new Error(
|
||||||
|
`Field detection failed for ${testCase.table}.${testCase.field}: ` +
|
||||||
|
`expected ${testCase.shouldEncrypt}, got ${result}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testKeyDerivation(): Promise<void> {
|
||||||
|
const password = 'test-password';
|
||||||
|
const fieldType1 = 'users.password_hash';
|
||||||
|
const fieldType2 = 'ssh_data.password';
|
||||||
|
|
||||||
|
const key1a = FieldEncryption.getFieldKey(password, fieldType1);
|
||||||
|
const key1b = FieldEncryption.getFieldKey(password, fieldType1);
|
||||||
|
const key2 = FieldEncryption.getFieldKey(password, fieldType2);
|
||||||
|
|
||||||
|
if (!key1a.equals(key1b)) {
|
||||||
|
throw new Error('Same field type should produce identical keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key1a.equals(key2)) {
|
||||||
|
throw new Error('Different field types should produce different keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
const differentPasswordKey = FieldEncryption.getFieldKey('different-password', fieldType1);
|
||||||
|
if (key1a.equals(differentPasswordKey)) {
|
||||||
|
throw new Error('Different passwords should produce different keys');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testDatabaseContext(): Promise<void> {
|
||||||
|
DatabaseEncryption.initialize({
|
||||||
|
masterPassword: this.testPassword,
|
||||||
|
encryptionEnabled: true,
|
||||||
|
forceEncryption: false,
|
||||||
|
migrateOnAccess: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = DatabaseEncryption.getEncryptionStatus();
|
||||||
|
if (!status.enabled) {
|
||||||
|
throw new Error('Encryption should be enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.configValid) {
|
||||||
|
throw new Error('Configuration should be valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testRecordOperations(): Promise<void> {
|
||||||
|
const testRecord = {
|
||||||
|
id: 'test-id-123',
|
||||||
|
username: 'testuser',
|
||||||
|
password_hash: 'sensitive-password-hash',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const encrypted = DatabaseEncryption.encryptRecord('users', testRecord);
|
||||||
|
const decrypted = DatabaseEncryption.decryptRecord('users', encrypted);
|
||||||
|
|
||||||
|
if (decrypted.username !== testRecord.username) {
|
||||||
|
throw new Error('Non-sensitive field should remain unchanged');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypted.password_hash !== testRecord.password_hash) {
|
||||||
|
throw new Error('Sensitive field should be properly decrypted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FieldEncryption.isEncrypted(encrypted.password_hash)) {
|
||||||
|
throw new Error('Sensitive field should be encrypted in stored record');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testBackwardCompatibility(): Promise<void> {
|
||||||
|
const plaintextRecord = {
|
||||||
|
id: 'legacy-id-456',
|
||||||
|
username: 'legacyuser',
|
||||||
|
password_hash: 'plain-text-password-hash',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrypted = DatabaseEncryption.decryptRecord('users', plaintextRecord);
|
||||||
|
|
||||||
|
if (decrypted.password_hash !== plaintextRecord.password_hash) {
|
||||||
|
throw new Error('Plain text fields should be returned as-is for backward compatibility');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypted.username !== plaintextRecord.username) {
|
||||||
|
throw new Error('Non-sensitive fields should be unchanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testErrorHandling(): Promise<void> {
|
||||||
|
const key = FieldEncryption.getFieldKey(this.testPassword, 'test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
FieldEncryption.decryptField('invalid-json-data', key);
|
||||||
|
throw new Error('Should have thrown error for invalid JSON');
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || !(error as Error).message.includes('decryption failed')) {
|
||||||
|
throw new Error('Should throw appropriate decryption error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fakeEncrypted = JSON.stringify({ data: 'fake', iv: 'fake', tag: 'fake' });
|
||||||
|
FieldEncryption.decryptField(fakeEncrypted, key);
|
||||||
|
throw new Error('Should have thrown error for invalid encrypted data');
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || !(error as Error).message.includes('Decryption failed')) {
|
||||||
|
throw new Error('Should throw appropriate error for corrupted data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPerformance(): Promise<void> {
|
||||||
|
const testData = 'Performance test data that is reasonably long to simulate real SSH keys and passwords.';
|
||||||
|
const key = FieldEncryption.getFieldKey(this.testPassword, 'performance-test');
|
||||||
|
|
||||||
|
const iterations = 100;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
const encrypted = FieldEncryption.encryptField(testData, key);
|
||||||
|
const decrypted = FieldEncryption.decryptField(encrypted, key);
|
||||||
|
|
||||||
|
if (decrypted !== testData) {
|
||||||
|
throw new Error(`Performance test failed at iteration ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const totalTime = endTime - startTime;
|
||||||
|
const avgTime = totalTime / iterations;
|
||||||
|
|
||||||
|
console.log(` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`);
|
||||||
|
|
||||||
|
if (avgTime > 50) {
|
||||||
|
console.log(' ⚠️ Warning: Encryption operations are slower than expected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async validateProduction(): Promise<boolean> {
|
||||||
|
console.log('🔒 Validating production encryption setup...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptionKey = process.env.DB_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
console.log('❌ DB_ENCRYPTION_KEY environment variable not set');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionKey === 'default-key-change-me') {
|
||||||
|
console.log('❌ DB_ENCRYPTION_KEY is using default value (INSECURE)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionKey.length < 16) {
|
||||||
|
console.log('❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseEncryption.initialize({
|
||||||
|
masterPassword: encryptionKey,
|
||||||
|
encryptionEnabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = DatabaseEncryption.getEncryptionStatus();
|
||||||
|
if (!status.configValid) {
|
||||||
|
console.log('❌ Encryption configuration validation failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Production encryption setup is valid');
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Production validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const testMode = process.argv[2];
|
||||||
|
|
||||||
|
if (testMode === 'production') {
|
||||||
|
EncryptionTest.validateProduction()
|
||||||
|
.then((success) => {
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Test execution failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const test = new EncryptionTest();
|
||||||
|
test.runAllTests()
|
||||||
|
.then((success) => {
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Test execution failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EncryptionTest };
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
interface EncryptedData {
|
||||||
|
data: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
salt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncryptionConfig {
|
||||||
|
algorithm: string;
|
||||||
|
keyLength: number;
|
||||||
|
ivLength: number;
|
||||||
|
saltLength: number;
|
||||||
|
iterations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FieldEncryption {
|
||||||
|
private static readonly CONFIG: EncryptionConfig = {
|
||||||
|
algorithm: 'aes-256-gcm',
|
||||||
|
keyLength: 32,
|
||||||
|
ivLength: 16,
|
||||||
|
saltLength: 32,
|
||||||
|
iterations: 100000,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly ENCRYPTED_FIELDS = {
|
||||||
|
users: ['password_hash', 'client_secret', 'totp_secret', 'totp_backup_codes', 'oidc_identifier'],
|
||||||
|
ssh_data: ['password', 'key', 'keyPassword'],
|
||||||
|
ssh_credentials: ['password', 'privateKey', 'keyPassword', 'key', 'publicKey']
|
||||||
|
};
|
||||||
|
|
||||||
|
static isEncrypted(value: string | null): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return !!(parsed.data && parsed.iv && parsed.tag);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static deriveKey(password: string, salt: Buffer, keyType: string): Buffer {
|
||||||
|
const masterKey = crypto.pbkdf2Sync(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
this.CONFIG.iterations,
|
||||||
|
this.CONFIG.keyLength,
|
||||||
|
'sha256'
|
||||||
|
);
|
||||||
|
|
||||||
|
return Buffer.from(crypto.hkdfSync(
|
||||||
|
'sha256',
|
||||||
|
masterKey,
|
||||||
|
salt,
|
||||||
|
keyType,
|
||||||
|
this.CONFIG.keyLength
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
static encrypt(plaintext: string, key: Buffer): EncryptedData {
|
||||||
|
if (!plaintext) return { data: '', iv: '', tag: '' };
|
||||||
|
|
||||||
|
const iv = crypto.randomBytes(this.CONFIG.ivLength);
|
||||||
|
const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any;
|
||||||
|
cipher.setAAD(Buffer.from('termix-field-encryption'));
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static decrypt(encryptedData: EncryptedData, key: Buffer): string {
|
||||||
|
if (!encryptedData.data) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv(this.CONFIG.algorithm, key, Buffer.from(encryptedData.iv, 'hex')) as any;
|
||||||
|
decipher.setAAD(Buffer.from('termix-field-encryption'));
|
||||||
|
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'));
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData.data, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static encryptField(value: string, fieldKey: Buffer): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (this.isEncrypted(value)) return value;
|
||||||
|
|
||||||
|
const encrypted = this.encrypt(value, fieldKey);
|
||||||
|
return JSON.stringify(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
static decryptField(value: string, fieldKey: Buffer): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (!this.isEncrypted(value)) return value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encrypted: EncryptedData = JSON.parse(value);
|
||||||
|
return this.decrypt(encrypted, fieldKey);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Field decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFieldKey(masterPassword: string, fieldType: string): Buffer {
|
||||||
|
const salt = crypto.createHash('sha256').update(`termix-${fieldType}`).digest();
|
||||||
|
return this.deriveKey(masterPassword, salt, fieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||||
|
const tableFields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||||
|
return tableFields ? tableFields.includes(fieldName) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateSalt(): string {
|
||||||
|
return crypto.randomBytes(this.CONFIG.saltLength).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateEncryptionHealth(encryptedValue: string, key: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!this.isEncrypted(encryptedValue)) return false;
|
||||||
|
const decrypted = this.decryptField(encryptedValue, key);
|
||||||
|
return decrypted !== '';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FieldEncryption };
|
||||||
|
export type { EncryptedData, EncryptionConfig };
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import os from 'os';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
|
||||||
|
interface HardwareInfo {
|
||||||
|
cpuId?: string;
|
||||||
|
motherboardUuid?: string;
|
||||||
|
diskSerial?: string;
|
||||||
|
biosSerial?: string;
|
||||||
|
tpmInfo?: string;
|
||||||
|
macAddresses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹生成器 - 使用真实硬件特征生成稳定的设备指纹
|
||||||
|
* 相比软件环境指纹,硬件指纹在虚拟化和容器环境中更加稳定
|
||||||
|
*/
|
||||||
|
class HardwareFingerprint {
|
||||||
|
private static readonly CACHE_KEY = 'cached_hardware_fingerprint';
|
||||||
|
private static cachedFingerprint: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成硬件指纹
|
||||||
|
* 优先级:缓存 > 环境变量 > 硬件检测
|
||||||
|
*/
|
||||||
|
static generate(): string {
|
||||||
|
try {
|
||||||
|
// 1. 检查缓存
|
||||||
|
if (this.cachedFingerprint) {
|
||||||
|
return this.cachedFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查环境变量覆盖
|
||||||
|
const envFingerprint = process.env.TERMIX_HARDWARE_SEED;
|
||||||
|
if (envFingerprint && envFingerprint.length >= 32) {
|
||||||
|
databaseLogger.info('Using hardware seed from environment variable', {
|
||||||
|
operation: 'hardware_fingerprint_env'
|
||||||
|
});
|
||||||
|
this.cachedFingerprint = this.hashFingerprint(envFingerprint);
|
||||||
|
return this.cachedFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检测真实硬件信息
|
||||||
|
const hwInfo = this.detectHardwareInfo();
|
||||||
|
const fingerprint = this.generateFromHardware(hwInfo);
|
||||||
|
|
||||||
|
this.cachedFingerprint = fingerprint;
|
||||||
|
|
||||||
|
databaseLogger.info('Generated hardware fingerprint', {
|
||||||
|
operation: 'hardware_fingerprint_generation',
|
||||||
|
fingerprintPrefix: fingerprint.substring(0, 8),
|
||||||
|
detectedComponents: Object.keys(hwInfo).filter(key => hwInfo[key as keyof HardwareInfo])
|
||||||
|
});
|
||||||
|
|
||||||
|
return fingerprint;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Hardware fingerprint generation failed', error, {
|
||||||
|
operation: 'hardware_fingerprint_failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 回退到基本的环境指纹
|
||||||
|
return this.generateFallbackFingerprint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测硬件信息
|
||||||
|
*/
|
||||||
|
private static detectHardwareInfo(): HardwareInfo {
|
||||||
|
const platform = os.platform();
|
||||||
|
const hwInfo: HardwareInfo = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (platform) {
|
||||||
|
case 'linux':
|
||||||
|
hwInfo.cpuId = this.getLinuxCpuId();
|
||||||
|
hwInfo.motherboardUuid = this.getLinuxMotherboardUuid();
|
||||||
|
hwInfo.diskSerial = this.getLinuxDiskSerial();
|
||||||
|
hwInfo.biosSerial = this.getLinuxBiosSerial();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'win32':
|
||||||
|
hwInfo.cpuId = this.getWindowsCpuId();
|
||||||
|
hwInfo.motherboardUuid = this.getWindowsMotherboardUuid();
|
||||||
|
hwInfo.diskSerial = this.getWindowsDiskSerial();
|
||||||
|
hwInfo.biosSerial = this.getWindowsBiosSerial();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'darwin':
|
||||||
|
hwInfo.cpuId = this.getMacOSCpuId();
|
||||||
|
hwInfo.motherboardUuid = this.getMacOSMotherboardUuid();
|
||||||
|
hwInfo.diskSerial = this.getMacOSDiskSerial();
|
||||||
|
hwInfo.biosSerial = this.getMacOSBiosSerial();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有平台都尝试获取MAC地址
|
||||||
|
hwInfo.macAddresses = this.getStableMacAddresses();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Some hardware detection failed', error, {
|
||||||
|
operation: 'hardware_detection_partial_failure',
|
||||||
|
platform
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hwInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux平台硬件信息获取
|
||||||
|
*/
|
||||||
|
private static getLinuxCpuId(): string | undefined {
|
||||||
|
try {
|
||||||
|
// 尝试多种方法获取CPU信息
|
||||||
|
const methods = [
|
||||||
|
() => fs.readFileSync('/proc/cpuinfo', 'utf8').match(/processor\s*:\s*(\d+)/)?.[1],
|
||||||
|
() => execSync('dmidecode -t processor | grep "ID:" | head -1', { encoding: 'utf8' }).trim(),
|
||||||
|
() => execSync('cat /proc/cpuinfo | grep "cpu family\\|model\\|stepping" | md5sum', { encoding: 'utf8' }).split(' ')[0]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
try {
|
||||||
|
const result = method();
|
||||||
|
if (result && result.length > 0) return result;
|
||||||
|
} catch { /* 继续尝试下一种方法 */ }
|
||||||
|
}
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getLinuxMotherboardUuid(): string | undefined {
|
||||||
|
try {
|
||||||
|
// 尝试多种方法获取主板UUID
|
||||||
|
const methods = [
|
||||||
|
() => fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(),
|
||||||
|
() => fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
|
||||||
|
() => execSync('dmidecode -s system-uuid', { encoding: 'utf8' }).trim()
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
try {
|
||||||
|
const result = method();
|
||||||
|
if (result && result.length > 0 && result !== 'Not Settable') return result;
|
||||||
|
} catch { /* 继续尝试下一种方法 */ }
|
||||||
|
}
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getLinuxDiskSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
// 获取根分区所在磁盘的序列号
|
||||||
|
const rootDisk = execSync("df / | tail -1 | awk '{print $1}' | sed 's/[0-9]*$//'", { encoding: 'utf8' }).trim();
|
||||||
|
if (rootDisk) {
|
||||||
|
const serial = execSync(`udevadm info --name=${rootDisk} | grep ID_SERIAL= | cut -d= -f2`, { encoding: 'utf8' }).trim();
|
||||||
|
if (serial && serial.length > 0) return serial;
|
||||||
|
}
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getLinuxBiosSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
const methods = [
|
||||||
|
() => fs.readFileSync('/sys/class/dmi/id/board_serial', 'utf8').trim(),
|
||||||
|
() => execSync('dmidecode -s baseboard-serial-number', { encoding: 'utf8' }).trim()
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
try {
|
||||||
|
const result = method();
|
||||||
|
if (result && result.length > 0 && result !== 'Not Specified') return result;
|
||||||
|
} catch { /* 继续尝试下一种方法 */ }
|
||||||
|
}
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows平台硬件信息获取
|
||||||
|
*/
|
||||||
|
private static getWindowsCpuId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('wmic cpu get ProcessorId /value', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/ProcessorId=(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getWindowsMotherboardUuid(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('wmic csproduct get UUID /value', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/UUID=(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getWindowsDiskSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('wmic diskdrive get SerialNumber /value', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/SerialNumber=(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getWindowsBiosSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('wmic baseboard get SerialNumber /value', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/SerialNumber=(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS平台硬件信息获取
|
||||||
|
*/
|
||||||
|
private static getMacOSCpuId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('sysctl -n machdep.cpu.brand_string', { encoding: 'utf8' });
|
||||||
|
return result.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getMacOSMotherboardUuid(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('system_profiler SPHardwareDataType | grep "Hardware UUID"', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/Hardware UUID:\s*(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getMacOSDiskSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('system_profiler SPStorageDataType | grep "Serial Number"', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/Serial Number:\s*(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getMacOSBiosSerial(): string | undefined {
|
||||||
|
try {
|
||||||
|
const result = execSync('system_profiler SPHardwareDataType | grep "Serial Number"', { encoding: 'utf8' });
|
||||||
|
const match = result.match(/Serial Number \(system\):\s*(.+)/);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
} catch { /* 忽略错误 */ }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取稳定的MAC地址
|
||||||
|
* 排除虚拟接口和临时接口
|
||||||
|
*/
|
||||||
|
private static getStableMacAddresses(): string[] {
|
||||||
|
try {
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
const macAddresses: string[] = [];
|
||||||
|
|
||||||
|
for (const [interfaceName, interfaces] of Object.entries(networkInterfaces)) {
|
||||||
|
if (!interfaces) continue;
|
||||||
|
|
||||||
|
// 排除虚拟接口和Docker接口
|
||||||
|
if (interfaceName.match(/^(lo|docker|veth|br-|virbr)/)) continue;
|
||||||
|
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
if (!iface.internal &&
|
||||||
|
iface.mac &&
|
||||||
|
iface.mac !== '00:00:00:00:00:00' &&
|
||||||
|
!iface.mac.startsWith('02:42:')) { // Docker接口特征
|
||||||
|
macAddresses.push(iface.mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return macAddresses.sort(); // 排序确保一致性
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从硬件信息生成指纹
|
||||||
|
*/
|
||||||
|
private static generateFromHardware(hwInfo: HardwareInfo): string {
|
||||||
|
const components = [
|
||||||
|
hwInfo.motherboardUuid, // 最稳定的标识符
|
||||||
|
hwInfo.cpuId,
|
||||||
|
hwInfo.biosSerial,
|
||||||
|
hwInfo.diskSerial,
|
||||||
|
hwInfo.macAddresses?.join(','),
|
||||||
|
os.platform(), // 操作系统平台
|
||||||
|
os.arch() // CPU架构
|
||||||
|
].filter(Boolean); // 过滤空值
|
||||||
|
|
||||||
|
if (components.length === 0) {
|
||||||
|
throw new Error('No hardware identifiers found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.hashFingerprint(components.join('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成回退指纹(当硬件检测失败时)
|
||||||
|
*/
|
||||||
|
private static generateFallbackFingerprint(): string {
|
||||||
|
const fallbackComponents = [
|
||||||
|
os.hostname(),
|
||||||
|
os.platform(),
|
||||||
|
os.arch(),
|
||||||
|
process.cwd(),
|
||||||
|
'fallback-mode'
|
||||||
|
];
|
||||||
|
|
||||||
|
databaseLogger.warn('Using fallback fingerprint due to hardware detection failure', {
|
||||||
|
operation: 'hardware_fingerprint_fallback'
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.hashFingerprint(fallbackComponents.join('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化指纹哈希
|
||||||
|
*/
|
||||||
|
private static hashFingerprint(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取硬件指纹信息(用于调试和显示)
|
||||||
|
*/
|
||||||
|
static getHardwareInfo(): HardwareInfo & { fingerprint: string } {
|
||||||
|
const hwInfo = this.detectHardwareInfo();
|
||||||
|
return {
|
||||||
|
...hwInfo,
|
||||||
|
fingerprint: this.generate().substring(0, 16)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前硬件指纹
|
||||||
|
*/
|
||||||
|
static validateFingerprint(expectedFingerprint: string): boolean {
|
||||||
|
try {
|
||||||
|
const currentFingerprint = this.generate();
|
||||||
|
return currentFingerprint === expectedFingerprint;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存(用于测试)
|
||||||
|
*/
|
||||||
|
static clearCache(): void {
|
||||||
|
this.cachedFingerprint = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HardwareFingerprint };
|
||||||
|
export type { HardwareInfo };
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { databaseLogger } from './logger.js';
|
||||||
|
import { HardwareFingerprint } from './hardware-fingerprint.js';
|
||||||
|
|
||||||
|
interface ProtectedKeyData {
|
||||||
|
data: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MasterKeyProtection {
|
||||||
|
private static readonly VERSION = 'v1';
|
||||||
|
private static readonly KEK_SALT = 'termix-kek-salt-v1';
|
||||||
|
private static readonly KEK_ITERATIONS = 50000;
|
||||||
|
|
||||||
|
private static generateDeviceFingerprint(): string {
|
||||||
|
try {
|
||||||
|
const fingerprint = HardwareFingerprint.generate();
|
||||||
|
|
||||||
|
databaseLogger.debug('Generated hardware fingerprint', {
|
||||||
|
operation: 'hardware_fingerprint_generation',
|
||||||
|
fingerprintPrefix: fingerprint.substring(0, 8)
|
||||||
|
});
|
||||||
|
|
||||||
|
return fingerprint;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to generate hardware fingerprint', error, {
|
||||||
|
operation: 'hardware_fingerprint_generation_failed'
|
||||||
|
});
|
||||||
|
throw new Error('Hardware fingerprint generation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static deriveKEK(): Buffer {
|
||||||
|
const fingerprint = this.generateDeviceFingerprint();
|
||||||
|
const salt = Buffer.from(this.KEK_SALT);
|
||||||
|
|
||||||
|
const kek = crypto.pbkdf2Sync(
|
||||||
|
fingerprint,
|
||||||
|
salt,
|
||||||
|
this.KEK_ITERATIONS,
|
||||||
|
32,
|
||||||
|
'sha256'
|
||||||
|
);
|
||||||
|
|
||||||
|
databaseLogger.debug('Derived KEK from hardware fingerprint', {
|
||||||
|
operation: 'kek_derivation',
|
||||||
|
iterations: this.KEK_ITERATIONS
|
||||||
|
});
|
||||||
|
|
||||||
|
return kek;
|
||||||
|
}
|
||||||
|
|
||||||
|
static encryptMasterKey(masterKey: string): string {
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('Master key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const kek = this.deriveKEK();
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv) as any;
|
||||||
|
|
||||||
|
let encrypted = cipher.update(masterKey, 'hex', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
const protectedData: ProtectedKeyData = {
|
||||||
|
data: encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex'),
|
||||||
|
version: this.VERSION,
|
||||||
|
fingerprint: this.generateDeviceFingerprint().substring(0, 16)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = JSON.stringify(protectedData);
|
||||||
|
|
||||||
|
databaseLogger.info('Master key encrypted with hardware KEK', {
|
||||||
|
operation: 'master_key_encryption',
|
||||||
|
version: this.VERSION,
|
||||||
|
fingerprintPrefix: protectedData.fingerprint
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to encrypt master key', error, {
|
||||||
|
operation: 'master_key_encryption_failed'
|
||||||
|
});
|
||||||
|
throw new Error('Master key encryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static decryptMasterKey(encryptedKey: string): string {
|
||||||
|
if (!encryptedKey) {
|
||||||
|
throw new Error('Encrypted key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
|
||||||
|
|
||||||
|
if (protectedData.version !== this.VERSION) {
|
||||||
|
throw new Error(`Unsupported protection version: ${protectedData.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16);
|
||||||
|
if (protectedData.fingerprint !== currentFingerprint) {
|
||||||
|
databaseLogger.warn('Hardware fingerprint mismatch detected', {
|
||||||
|
operation: 'master_key_decryption',
|
||||||
|
expected: protectedData.fingerprint,
|
||||||
|
current: currentFingerprint
|
||||||
|
});
|
||||||
|
throw new Error('Hardware fingerprint mismatch - key was encrypted on different hardware');
|
||||||
|
}
|
||||||
|
|
||||||
|
const kek = this.deriveKEK();
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', kek, Buffer.from(protectedData.iv, 'hex')) as any;
|
||||||
|
decipher.setAuthTag(Buffer.from(protectedData.tag, 'hex'));
|
||||||
|
|
||||||
|
let decrypted = decipher.update(protectedData.data, 'hex', 'hex');
|
||||||
|
decrypted += decipher.final('hex');
|
||||||
|
|
||||||
|
databaseLogger.debug('Master key decrypted successfully', {
|
||||||
|
operation: 'master_key_decryption',
|
||||||
|
version: protectedData.version
|
||||||
|
});
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Failed to decrypt master key', error, {
|
||||||
|
operation: 'master_key_decryption_failed'
|
||||||
|
});
|
||||||
|
throw new Error(`Master key decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static isProtectedKey(data: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return !!(parsed.data && parsed.iv && parsed.tag && parsed.version && parsed.fingerprint);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateProtection(): boolean {
|
||||||
|
try {
|
||||||
|
const testKey = crypto.randomBytes(32).toString('hex');
|
||||||
|
const encrypted = this.encryptMasterKey(testKey);
|
||||||
|
const decrypted = this.decryptMasterKey(encrypted);
|
||||||
|
|
||||||
|
const isValid = decrypted === testKey;
|
||||||
|
|
||||||
|
databaseLogger.info('Master key protection validation completed', {
|
||||||
|
operation: 'protection_validation',
|
||||||
|
result: isValid ? 'passed' : 'failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error('Master key protection validation failed', error, {
|
||||||
|
operation: 'protection_validation_failed'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getProtectionInfo(encryptedKey: string): {
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
isCurrentDevice: boolean;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
if (!this.isProtectedKey(encryptedKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
|
||||||
|
const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: protectedData.version,
|
||||||
|
fingerprint: protectedData.fingerprint,
|
||||||
|
isCurrentDevice: protectedData.fingerprint === currentFingerprint
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MasterKeyProtection };
|
||||||
|
export type { ProtectedKeyData };
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
// Import SSH2 using ES modules
|
||||||
|
import ssh2Pkg from 'ssh2';
|
||||||
|
const ssh2Utils = ssh2Pkg.utils;
|
||||||
|
|
||||||
|
// Simple fallback SSH key type detection
|
||||||
|
function detectKeyTypeFromContent(keyContent: string): string {
|
||||||
|
const content = keyContent.trim();
|
||||||
|
|
||||||
|
// Check for OpenSSH format headers
|
||||||
|
if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
|
||||||
|
// Look for key type indicators in the content
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For OpenSSH format, try to detect by analyzing the base64 content structure
|
||||||
|
try {
|
||||||
|
const base64Content = content
|
||||||
|
.replace('-----BEGIN OPENSSH PRIVATE KEY-----', '')
|
||||||
|
.replace('-----END OPENSSH PRIVATE KEY-----', '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
// OpenSSH format starts with "openssh-key-v1" followed by key type
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to RSA for OpenSSH format if we can't detect specifically
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to decode OpenSSH key content:', error);
|
||||||
|
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for traditional PEM headers
|
||||||
|
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'; // Default ECDSA type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for PKCS#8 format (modern format)
|
||||||
|
if (content.includes('-----BEGIN PRIVATE KEY-----')) {
|
||||||
|
// Try to decode and analyze the DER structure for better detection
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Check for algorithm identifiers in the DER structure
|
||||||
|
if (decodedString.includes('1.2.840.113549.1.1.1')) {
|
||||||
|
// RSA OID
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else if (decodedString.includes('1.2.840.10045.2.1')) {
|
||||||
|
// EC Private Key OID - this indicates ECDSA
|
||||||
|
if (decodedString.includes('1.2.840.10045.3.1.7')) {
|
||||||
|
// prime256v1 curve OID
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
return 'ecdsa-sha2-nistp256'; // Default to P-256
|
||||||
|
} else if (decodedString.includes('1.3.101.112')) {
|
||||||
|
// Ed25519 OID
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, fall back to length-based detection
|
||||||
|
console.warn('Failed to decode private key for type detection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to detect key type from the content structure
|
||||||
|
// This is a fallback for PKCS#8 format keys
|
||||||
|
if (content.length < 800) {
|
||||||
|
// Ed25519 keys are typically shorter
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
} else if (content.length > 1600) {
|
||||||
|
// RSA keys are typically longer
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else {
|
||||||
|
// ECDSA keys are typically medium length
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect public key type from public key content
|
||||||
|
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||||
|
const content = publicKeyContent.trim();
|
||||||
|
|
||||||
|
// SSH public keys start with the key type
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for PEM format public keys
|
||||||
|
if (content.includes('-----BEGIN PUBLIC KEY-----')) {
|
||||||
|
// Try to decode the base64 content to detect key type
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Check for algorithm identifiers in the DER structure
|
||||||
|
if (decodedString.includes('1.2.840.113549.1.1.1')) {
|
||||||
|
// RSA OID
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else if (decodedString.includes('1.2.840.10045.2.1')) {
|
||||||
|
// EC Public Key OID - this indicates ECDSA
|
||||||
|
if (decodedString.includes('1.2.840.10045.3.1.7')) {
|
||||||
|
// prime256v1 curve OID
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
return 'ecdsa-sha2-nistp256'; // Default to P-256
|
||||||
|
} else if (decodedString.includes('1.3.101.112')) {
|
||||||
|
// Ed25519 OID
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, fall back to length-based detection
|
||||||
|
console.warn('Failed to decode public key for type detection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to guess based on key length
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for base64 encoded key data patterns
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SSH private key and extract public key and type information
|
||||||
|
*/
|
||||||
|
export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo {
|
||||||
|
console.log('=== SSH Key Parsing Debug ===');
|
||||||
|
console.log('Key length:', privateKeyData?.length || 'undefined');
|
||||||
|
console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined');
|
||||||
|
console.log('ssh2Utils available:', typeof ssh2Utils);
|
||||||
|
console.log('parseKey function available:', typeof ssh2Utils?.parseKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let keyType = 'unknown';
|
||||||
|
let publicKey = '';
|
||||||
|
let useSSH2 = false;
|
||||||
|
|
||||||
|
// Try SSH2 first if available
|
||||||
|
if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') {
|
||||||
|
try {
|
||||||
|
console.log('Calling ssh2Utils.parseKey...');
|
||||||
|
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||||
|
console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success');
|
||||||
|
|
||||||
|
if (!(parsedKey instanceof Error)) {
|
||||||
|
// Extract key type
|
||||||
|
if (parsedKey.type) {
|
||||||
|
keyType = parsedKey.type;
|
||||||
|
}
|
||||||
|
console.log('Extracted key type:', keyType);
|
||||||
|
|
||||||
|
// Generate public key in SSH format
|
||||||
|
try {
|
||||||
|
console.log('Attempting to generate public key...');
|
||||||
|
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||||
|
console.log('Public key buffer type:', typeof publicKeyBuffer);
|
||||||
|
console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer));
|
||||||
|
|
||||||
|
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
||||||
|
// We need to convert this to proper SSH public key format
|
||||||
|
if (Buffer.isBuffer(publicKeyBuffer)) {
|
||||||
|
// Convert binary SSH data to base64 and create proper SSH key format
|
||||||
|
const base64Data = publicKeyBuffer.toString('base64');
|
||||||
|
|
||||||
|
// Create proper SSH public key format: "keytype base64data"
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generated SSH public key format, length:', publicKey.length);
|
||||||
|
console.log('Public key starts with:', publicKey.substring(0, 50));
|
||||||
|
} else {
|
||||||
|
console.warn('Unexpected public key buffer type');
|
||||||
|
publicKey = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to generate public key:', error);
|
||||||
|
publicKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
useSSH2 = true;
|
||||||
|
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
|
||||||
|
} else {
|
||||||
|
console.warn('SSH2 parsing failed:', parsedKey.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('SSH2 parseKey function not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to content-based detection
|
||||||
|
if (!useSSH2) {
|
||||||
|
console.log('Using fallback key type detection...');
|
||||||
|
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
|
console.log(`Fallback detected key type: ${keyType}`);
|
||||||
|
|
||||||
|
// For fallback, we can't generate public key but the detection is still useful
|
||||||
|
publicKey = '';
|
||||||
|
|
||||||
|
if (keyType !== 'unknown') {
|
||||||
|
console.log(`SSH key type detected successfully with fallback: ${keyType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey,
|
||||||
|
keyType,
|
||||||
|
success: keyType !== 'unknown'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during SSH key parsing:', error);
|
||||||
|
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||||
|
|
||||||
|
// Final fallback - try content detection
|
||||||
|
try {
|
||||||
|
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
|
if (fallbackKeyType !== 'unknown') {
|
||||||
|
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey: '',
|
||||||
|
keyType: fallbackKeyType,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Even fallback detection failed:', fallbackError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey: '',
|
||||||
|
keyType: 'unknown',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error parsing key'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SSH public key and extract type information
|
||||||
|
*/
|
||||||
|
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||||
|
console.log('=== SSH Public Key Parsing Debug ===');
|
||||||
|
console.log('Public key length:', publicKeyData?.length || 'undefined');
|
||||||
|
console.log('First 100 chars:', publicKeyData?.substring(0, 100) || 'undefined');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||||
|
console.log(`Public key type detected: ${keyType}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyData,
|
||||||
|
keyType,
|
||||||
|
success: keyType !== 'unknown'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during SSH public key parsing:', error);
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyData,
|
||||||
|
keyType: 'unknown',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error parsing public key'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect SSH key type from private key content
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get friendly key type name
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a private key and public key form a valid key pair
|
||||||
|
*/
|
||||||
|
export function validateKeyPair(privateKeyData: string, publicKeyData: string, passphrase?: string): KeyPairValidationResult {
|
||||||
|
console.log('=== Key Pair Validation Debug ===');
|
||||||
|
console.log('Private key length:', privateKeyData?.length || 'undefined');
|
||||||
|
console.log('Public key length:', publicKeyData?.length || 'undefined');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First parse the private key and try to generate public key
|
||||||
|
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||||
|
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||||
|
|
||||||
|
console.log('Private key parsing result:', privateKeyInfo.success, privateKeyInfo.keyType);
|
||||||
|
console.log('Public key parsing result:', publicKeyInfo.success, publicKeyInfo.keyType);
|
||||||
|
|
||||||
|
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}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key types match
|
||||||
|
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 we have a generated public key from the private key, compare them
|
||||||
|
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
||||||
|
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||||
|
const providedPublicKey = publicKeyData.trim();
|
||||||
|
|
||||||
|
console.log('Generated public key length:', generatedPublicKey.length);
|
||||||
|
console.log('Provided public key length:', providedPublicKey.length);
|
||||||
|
|
||||||
|
// Compare the key data part (excluding comments)
|
||||||
|
const generatedKeyParts = generatedPublicKey.split(' ');
|
||||||
|
const providedKeyParts = providedPublicKey.split(' ');
|
||||||
|
|
||||||
|
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
||||||
|
// Compare key type and key data (first two parts)
|
||||||
|
const generatedKeyData = generatedKeyParts[0] + ' ' + generatedKeyParts[1];
|
||||||
|
const providedKeyData = providedKeyParts[0] + ' ' + providedKeyParts[1];
|
||||||
|
|
||||||
|
console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...');
|
||||||
|
console.log('Provided key data:', providedKeyData.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't generate public key or compare, just check if types match
|
||||||
|
return {
|
||||||
|
isValid: true, // Assume valid if types match and no errors
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
error: 'Unable to verify key pair match, but key types are compatible'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during key pair validation:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: 'unknown',
|
||||||
|
publicKeyType: 'unknown',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error during validation'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,27 @@
|
|||||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
"failedToRenameFolder": "Failed to rename folder",
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
|
"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",
|
||||||
|
"generateKeyPair": "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."
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
@@ -353,7 +373,121 @@
|
|||||||
"deleteUser": "Delete user {{username}}? This cannot be undone.",
|
"deleteUser": "Delete user {{username}}? This cannot be undone.",
|
||||||
"userDeletedSuccessfully": "User {{username}} deleted successfully",
|
"userDeletedSuccessfully": "User {{username}} deleted successfully",
|
||||||
"failedToDeleteUser": "Failed to delete user",
|
"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",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "Host Manager",
|
"title": "Host Manager",
|
||||||
@@ -398,6 +532,7 @@
|
|||||||
"mustSelectValidSshConfig": "Must select a valid SSH configuration from the list",
|
"mustSelectValidSshConfig": "Must select a valid SSH configuration from the list",
|
||||||
"addHost": "Add Host",
|
"addHost": "Add Host",
|
||||||
"editHost": "Edit Host",
|
"editHost": "Edit Host",
|
||||||
|
"cloneHost": "Clone Host",
|
||||||
"updateHost": "Update Host",
|
"updateHost": "Update Host",
|
||||||
"hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!",
|
"hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!",
|
||||||
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
|
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
|
||||||
@@ -441,6 +576,8 @@
|
|||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"requirePassword": "Require Password",
|
||||||
|
"requirePasswordDescription": "When disabled, sessions can be saved without entering a password",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"credential": "Credential",
|
"credential": "Credential",
|
||||||
"selectCredential": "Select Credential",
|
"selectCredential": "Select Credential",
|
||||||
@@ -518,6 +655,7 @@
|
|||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"connectToSsh": "Connect to SSH to use file operations",
|
"connectToSsh": "Connect to SSH to use file operations",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
|
"downloadFile": "Download File",
|
||||||
"newFile": "New File",
|
"newFile": "New File",
|
||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
@@ -530,7 +668,9 @@
|
|||||||
"clickToSelectFile": "Click to select a file",
|
"clickToSelectFile": "Click to select a file",
|
||||||
"chooseFile": "Choose File",
|
"chooseFile": "Choose File",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
|
"downloading": "Downloading...",
|
||||||
"uploadingFile": "Uploading {{name}}...",
|
"uploadingFile": "Uploading {{name}}...",
|
||||||
|
"downloadingFile": "Downloading {{name}}...",
|
||||||
"creatingFile": "Creating {{name}}...",
|
"creatingFile": "Creating {{name}}...",
|
||||||
"creatingFolder": "Creating {{name}}...",
|
"creatingFolder": "Creating {{name}}...",
|
||||||
"deletingItem": "Deleting {{type}} {{name}}...",
|
"deletingItem": "Deleting {{type}} {{name}}...",
|
||||||
@@ -552,11 +692,42 @@
|
|||||||
"renaming": "Renaming...",
|
"renaming": "Renaming...",
|
||||||
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
|
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
|
||||||
"failedToUploadFile": "Failed to upload file",
|
"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",
|
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
|
||||||
"failedToCreateFile": "Failed to create file",
|
"failedToCreateFile": "Failed to create file",
|
||||||
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
||||||
"failedToCreateFolder": "Failed to create folder",
|
"failedToCreateFolder": "Failed to create folder",
|
||||||
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
"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",
|
||||||
|
"copyFiles": "Copy {{count}} items",
|
||||||
|
"cutFiles": "Cut {{count}} items",
|
||||||
|
"deleteFiles": "Delete {{count}} items",
|
||||||
|
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
|
||||||
|
"filesCutToClipboard": "{{count}} items cut to clipboard",
|
||||||
"failedToDeleteItem": "Failed to delete item",
|
"failedToDeleteItem": "Failed to delete item",
|
||||||
"itemRenamedSuccessfully": "{{type}} renamed successfully",
|
"itemRenamedSuccessfully": "{{type}} renamed successfully",
|
||||||
"failedToRenameItem": "Failed to rename item",
|
"failedToRenameItem": "Failed to rename item",
|
||||||
@@ -617,7 +788,40 @@
|
|||||||
"sshStatusCheckTimeout": "SSH status check timed out",
|
"sshStatusCheckTimeout": "SSH status check timed out",
|
||||||
"sshReconnectionTimeout": "SSH reconnection timed out",
|
"sshReconnectionTimeout": "SSH reconnection timed out",
|
||||||
"saveOperationTimeout": "Save operation 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 to System",
|
||||||
|
"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 to system",
|
||||||
|
"saveToSystem": "Save to system",
|
||||||
|
"pinFile": "Pin file",
|
||||||
|
"addToShortcuts": "Add to shortcuts",
|
||||||
|
"selectLocationToSave": "Select location to save",
|
||||||
|
"downloadToDefaultLocation": "Download to default location"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH Tunnels",
|
"title": "SSH Tunnels",
|
||||||
@@ -878,6 +1082,7 @@
|
|||||||
"password": "password",
|
"password": "password",
|
||||||
"keyPassword": "key password",
|
"keyPassword": "key password",
|
||||||
"pastePrivateKey": "Paste your private key here...",
|
"pastePrivateKey": "Paste your private key here...",
|
||||||
|
"pastePublicKey": "Paste your public key here...",
|
||||||
"credentialName": "My SSH Server",
|
"credentialName": "My SSH Server",
|
||||||
"description": "SSH credential description",
|
"description": "SSH credential description",
|
||||||
"searchCredentials": "Search credentials by name, username, or tags...",
|
"searchCredentials": "Search credentials by name, username, or tags...",
|
||||||
|
|||||||
@@ -129,7 +129,27 @@
|
|||||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
"failedToRenameFolder": "重命名文件夹失败",
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
"failedToMoveToFolder": "移动凭据到文件夹失败"
|
"failedToMoveToFolder": "移动凭据到文件夹失败",
|
||||||
|
"sshPublicKey": "SSH公钥",
|
||||||
|
"publicKeyNote": "公钥是可选的,但建议提供以验证密钥对",
|
||||||
|
"publicKeyUploaded": "公钥已上传",
|
||||||
|
"uploadPublicKey": "上传公钥",
|
||||||
|
"uploadPrivateKeyFile": "上传私钥文件",
|
||||||
|
"uploadPublicKeyFile": "上传公钥文件",
|
||||||
|
"privateKeyRequiredForGeneration": "生成公钥需要先输入私钥",
|
||||||
|
"failedToGeneratePublicKey": "生成公钥失败",
|
||||||
|
"generatePublicKey": "从私钥生成",
|
||||||
|
"publicKeyGeneratedSuccessfully": "公钥生成成功",
|
||||||
|
"detectedKeyType": "检测到的密钥类型",
|
||||||
|
"detectingKeyType": "检测中...",
|
||||||
|
"optional": "可选",
|
||||||
|
"generateKeyPair": "生成新的密钥对",
|
||||||
|
"generateEd25519": "生成 Ed25519",
|
||||||
|
"generateECDSA": "生成 ECDSA",
|
||||||
|
"generateRSA": "生成 RSA",
|
||||||
|
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
|
||||||
|
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||||
|
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。"
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
@@ -339,7 +359,121 @@
|
|||||||
"failedToRemoveAdminStatus": "移除管理员权限失败",
|
"failedToRemoveAdminStatus": "移除管理员权限失败",
|
||||||
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
|
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
|
||||||
"failedToDeleteUser": "删除用户失败",
|
"failedToDeleteUser": "删除用户失败",
|
||||||
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)"
|
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)",
|
||||||
|
"databaseSecurity": "数据库安全",
|
||||||
|
"encryptionStatus": "加密状态",
|
||||||
|
"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": "数据迁移完成!",
|
||||||
|
"migrationCompleted": "迁移完成",
|
||||||
|
"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": "导入"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "主机管理",
|
"title": "主机管理",
|
||||||
@@ -384,6 +518,7 @@
|
|||||||
"mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置",
|
"mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置",
|
||||||
"addHost": "添加主机",
|
"addHost": "添加主机",
|
||||||
"editHost": "编辑主机",
|
"editHost": "编辑主机",
|
||||||
|
"cloneHost": "克隆主机",
|
||||||
"deleteHost": "删除主机",
|
"deleteHost": "删除主机",
|
||||||
"authType": "认证类型",
|
"authType": "认证类型",
|
||||||
"passwordAuth": "密码",
|
"passwordAuth": "密码",
|
||||||
@@ -441,6 +576,8 @@
|
|||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"authentication": "认证方式",
|
"authentication": "认证方式",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"requirePassword": "要求密码",
|
||||||
|
"requirePasswordDescription": "禁用时,可以在不输入密码的情况下保存会话",
|
||||||
"key": "密钥",
|
"key": "密钥",
|
||||||
"credential": "凭证",
|
"credential": "凭证",
|
||||||
"selectCredential": "选择凭证",
|
"selectCredential": "选择凭证",
|
||||||
@@ -533,6 +670,7 @@
|
|||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
|
"downloadFile": "下载文件",
|
||||||
"newFile": "新建文件",
|
"newFile": "新建文件",
|
||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
@@ -545,7 +683,9 @@
|
|||||||
"clickToSelectFile": "点击选择文件",
|
"clickToSelectFile": "点击选择文件",
|
||||||
"chooseFile": "选择文件",
|
"chooseFile": "选择文件",
|
||||||
"uploading": "上传中...",
|
"uploading": "上传中...",
|
||||||
|
"downloading": "下载中...",
|
||||||
"uploadingFile": "正在上传 {{name}}...",
|
"uploadingFile": "正在上传 {{name}}...",
|
||||||
|
"downloadingFile": "正在下载 {{name}}...",
|
||||||
"creatingFile": "正在创建 {{name}}...",
|
"creatingFile": "正在创建 {{name}}...",
|
||||||
"creatingFolder": "正在创建 {{name}}...",
|
"creatingFolder": "正在创建 {{name}}...",
|
||||||
"deletingItem": "正在删除 {{type}} {{name}}...",
|
"deletingItem": "正在删除 {{type}} {{name}}...",
|
||||||
@@ -567,11 +707,42 @@
|
|||||||
"renaming": "重命名中...",
|
"renaming": "重命名中...",
|
||||||
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
|
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
|
||||||
"failedToUploadFile": "上传文件失败",
|
"failedToUploadFile": "上传文件失败",
|
||||||
|
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
|
||||||
|
"failedToDownloadFile": "下载文件失败",
|
||||||
|
"noFileContent": "未收到文件内容",
|
||||||
|
"filePath": "文件路径",
|
||||||
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
|
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
|
||||||
"failedToCreateFile": "创建文件失败",
|
"failedToCreateFile": "创建文件失败",
|
||||||
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
||||||
"failedToCreateFolder": "创建文件夹失败",
|
"failedToCreateFolder": "创建文件夹失败",
|
||||||
"itemDeletedSuccessfully": "{{type}}删除成功",
|
"itemDeletedSuccessfully": "{{type}}删除成功",
|
||||||
|
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
|
||||||
|
"failedToDeleteItems": "删除项目失败",
|
||||||
|
"dragFilesToUpload": "拖拽文件到这里上传",
|
||||||
|
"emptyFolder": "此文件夹为空",
|
||||||
|
"itemCount": "{{count}} 个项目",
|
||||||
|
"selectedCount": "已选择 {{count}} 个",
|
||||||
|
"searchFiles": "搜索文件...",
|
||||||
|
"upload": "上传",
|
||||||
|
"selectHostToStart": "选择主机开始文件管理",
|
||||||
|
"failedToConnect": "连接SSH失败",
|
||||||
|
"failedToLoadDirectory": "加载目录失败",
|
||||||
|
"noSSHConnection": "无SSH连接可用",
|
||||||
|
"enterFolderName": "输入文件夹名称:",
|
||||||
|
"enterFileName": "输入文件名称:",
|
||||||
|
"copy": "复制",
|
||||||
|
"cut": "剪切",
|
||||||
|
"paste": "粘贴",
|
||||||
|
"delete": "删除",
|
||||||
|
"properties": "属性",
|
||||||
|
"preview": "预览",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"downloadFiles": "下载 {{count}} 个文件",
|
||||||
|
"copyFiles": "复制 {{count}} 个项目",
|
||||||
|
"cutFiles": "剪切 {{count}} 个项目",
|
||||||
|
"deleteFiles": "删除 {{count}} 个项目",
|
||||||
|
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
|
||||||
|
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
|
||||||
"failedToDeleteItem": "删除项目失败",
|
"failedToDeleteItem": "删除项目失败",
|
||||||
"itemRenamedSuccessfully": "{{type}}重命名成功",
|
"itemRenamedSuccessfully": "{{type}}重命名成功",
|
||||||
"failedToRenameItem": "重命名项目失败",
|
"failedToRenameItem": "重命名项目失败",
|
||||||
@@ -608,6 +779,18 @@
|
|||||||
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
|
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
|
||||||
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
|
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
|
||||||
"actionCannotBeUndone": "此操作无法撤销。",
|
"actionCannotBeUndone": "此操作无法撤销。",
|
||||||
|
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
|
||||||
|
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
|
||||||
|
"openTerminalHere": "在此处打开终端",
|
||||||
|
"run": "运行",
|
||||||
|
"saveToSystem": "保存到系统",
|
||||||
|
"selectLocationToSave": "选择位置保存",
|
||||||
|
"openTerminalInFolder": "在此文件夹打开终端",
|
||||||
|
"openTerminalInFileLocation": "在文件位置打开终端",
|
||||||
|
"terminalWithPath": "终端 - {{host}}:{{path}}",
|
||||||
|
"runningFile": "运行 - {{file}}",
|
||||||
|
"onlyRunExecutableFiles": "只能运行可执行文件",
|
||||||
|
"noHostSelected": "没有选择主机",
|
||||||
"recent": "最近的",
|
"recent": "最近的",
|
||||||
"pinned": "固定的",
|
"pinned": "固定的",
|
||||||
"folderShortcuts": "文件夹快捷方式",
|
"folderShortcuts": "文件夹快捷方式",
|
||||||
@@ -624,7 +807,28 @@
|
|||||||
"sshStatusCheckTimeout": "SSH 状态检查超时",
|
"sshStatusCheckTimeout": "SSH 状态检查超时",
|
||||||
"sshReconnectionTimeout": "SSH 重新连接超时",
|
"sshReconnectionTimeout": "SSH 重新连接超时",
|
||||||
"saveOperationTimeout": "保存操作超时",
|
"saveOperationTimeout": "保存操作超时",
|
||||||
"cannotSaveFile": "无法保存文件"
|
"cannotSaveFile": "无法保存文件",
|
||||||
|
"starred": "收藏",
|
||||||
|
"shortcuts": "快捷方式",
|
||||||
|
"directories": "目录",
|
||||||
|
"removedFromRecentFiles": "已从最近访问中移除\"{{name}}\"",
|
||||||
|
"removeFailed": "移除失败",
|
||||||
|
"unpinnedSuccessfully": "已取消固定\"{{name}}\"",
|
||||||
|
"unpinFailed": "取消固定失败",
|
||||||
|
"removedShortcut": "已移除快捷方式\"{{name}}\"",
|
||||||
|
"removeShortcutFailed": "移除快捷方式失败",
|
||||||
|
"clearedAllRecentFiles": "已清除所有最近访问记录",
|
||||||
|
"clearFailed": "清除失败",
|
||||||
|
"removeFromRecentFiles": "从最近访问中移除",
|
||||||
|
"clearAllRecentFiles": "清除所有最近访问",
|
||||||
|
"unpinFile": "取消固定",
|
||||||
|
"removeShortcut": "移除快捷方式",
|
||||||
|
"saveFilesToSystem": "保存 {{count}} 个文件到系统",
|
||||||
|
"saveToSystem": "保存到系统",
|
||||||
|
"pinFile": "固定文件",
|
||||||
|
"addToShortcuts": "添加到快捷方式",
|
||||||
|
"selectLocationToSave": "选择位置保存",
|
||||||
|
"downloadToDefaultLocation": "下载到默认位置"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH 隧道",
|
"title": "SSH 隧道",
|
||||||
@@ -874,6 +1078,7 @@
|
|||||||
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"pastePrivateKey": "在此粘贴您的私钥...",
|
"pastePrivateKey": "在此粘贴您的私钥...",
|
||||||
|
"pastePublicKey": "在此粘贴您的公钥...",
|
||||||
"sshConfig": "端点 SSH 配置",
|
"sshConfig": "端点 SSH 配置",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
"clientId": "您的客户端 ID",
|
"clientId": "您的客户端 ID",
|
||||||
|
|||||||
Vendored
+66
@@ -0,0 +1,66 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
// 拖拽API
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -70,6 +70,7 @@ export interface Credential {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
@@ -87,6 +88,7 @@ export interface CredentialData {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}
|
}
|
||||||
@@ -180,8 +182,15 @@ export interface FileItem {
|
|||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
type: "file" | "directory";
|
type: "file" | "directory" | "link";
|
||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: string;
|
||||||
|
permissions?: string;
|
||||||
|
owner?: string;
|
||||||
|
group?: string;
|
||||||
|
linkTarget?: string;
|
||||||
|
executable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortcutItem {
|
export interface ShortcutItem {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx";
|
} from "@/components/ui/table.tsx";
|
||||||
import { Shield, Trash2, Users } from "lucide-react";
|
import { Shield, Trash2, Users, Database, Key, Lock, Download, Upload, HardDrive, FileArchive } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
@@ -82,6 +82,20 @@ export function AdminSettings({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Database encryption state
|
||||||
|
const [encryptionStatus, setEncryptionStatus] = React.useState<any>(null);
|
||||||
|
const [encryptionLoading, setEncryptionLoading] = React.useState(false);
|
||||||
|
const [migrationLoading, setMigrationLoading] = React.useState(false);
|
||||||
|
const [migrationProgress, setMigrationProgress] = React.useState<string>("");
|
||||||
|
|
||||||
|
// Database migration state
|
||||||
|
const [exportLoading, setExportLoading] = React.useState(false);
|
||||||
|
const [importLoading, setImportLoading] = React.useState(false);
|
||||||
|
const [backupLoading, setBackupLoading] = React.useState(false);
|
||||||
|
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||||
|
const [exportPath, setExportPath] = React.useState<string>("");
|
||||||
|
const [backupPath, setBackupPath] = React.useState<string>("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (!jwt) return;
|
if (!jwt) return;
|
||||||
@@ -103,6 +117,7 @@ export function AdminSettings({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchEncryptionStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -251,6 +266,213 @@ export function AdminSettings({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchEncryptionStatus = async () => {
|
||||||
|
if (isElectron()) {
|
||||||
|
const serverUrl = (window as any).configuredServerUrl;
|
||||||
|
if (!serverUrl) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/encryption/status`
|
||||||
|
: "http://localhost:8081/encryption/status";
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setEncryptionStatus(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch encryption status:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitializeEncryption = async () => {
|
||||||
|
setEncryptionLoading(true);
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/encryption/initialize`
|
||||||
|
: "http://localhost:8081/encryption/initialize";
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
toast.success("Database encryption initialized successfully!");
|
||||||
|
await fetchEncryptionStatus();
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to initialize encryption");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to initialize encryption");
|
||||||
|
} finally {
|
||||||
|
setEncryptionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMigrateData = async (dryRun: boolean = false) => {
|
||||||
|
setMigrationLoading(true);
|
||||||
|
setMigrationProgress(dryRun ? t("admin.runningVerification") : t("admin.startingMigration"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/encryption/migrate`
|
||||||
|
: "http://localhost:8081/encryption/migrate";
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ dryRun }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (dryRun) {
|
||||||
|
toast.success(t("admin.verificationCompleted"));
|
||||||
|
setMigrationProgress(t("admin.verificationInProgress"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("admin.dataMigrationCompleted"));
|
||||||
|
setMigrationProgress(t("admin.migrationCompleted"));
|
||||||
|
await fetchEncryptionStatus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Migration failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed"));
|
||||||
|
setMigrationProgress("Failed");
|
||||||
|
} finally {
|
||||||
|
setMigrationLoading(false);
|
||||||
|
setTimeout(() => setMigrationProgress(""), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Database export/import handlers
|
||||||
|
const handleExportDatabase = async () => {
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/database/export`
|
||||||
|
: "http://localhost:8081/database/export";
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
setExportPath(result.exportPath);
|
||||||
|
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||||
|
} else {
|
||||||
|
throw new Error("Export failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("admin.databaseExportFailed"));
|
||||||
|
} finally {
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportDatabase = async () => {
|
||||||
|
if (!importFile) {
|
||||||
|
toast.error(t("admin.pleaseSelectImportFile"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportLoading(true);
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/database/import`
|
||||||
|
: "http://localhost:8081/database/import";
|
||||||
|
|
||||||
|
// Create FormData for file upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', importFile);
|
||||||
|
formData.append('backupCurrent', 'true');
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("admin.databaseImportedSuccessfully"));
|
||||||
|
setImportFile(null);
|
||||||
|
await fetchEncryptionStatus(); // Refresh status
|
||||||
|
} else {
|
||||||
|
toast.error(`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Import failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("admin.databaseImportFailed"));
|
||||||
|
} finally {
|
||||||
|
setImportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateBackup = async () => {
|
||||||
|
setBackupLoading(true);
|
||||||
|
try {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as any).configuredServerUrl}/database/backup`
|
||||||
|
: "http://localhost:8081/database/backup";
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
setBackupPath(result.backupPath);
|
||||||
|
toast.success(t("admin.encryptedBackupCreatedSuccessfully"));
|
||||||
|
} else {
|
||||||
|
throw new Error("Backup failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("admin.backupCreationFailed"));
|
||||||
|
} finally {
|
||||||
|
setBackupLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -295,6 +517,10 @@ export function AdminSettings({
|
|||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
{t("admin.adminManagement")}
|
{t("admin.adminManagement")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
{t("admin.databaseSecurity")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="registration" className="space-y-6">
|
<TabsContent value="registration" className="space-y-6">
|
||||||
@@ -680,6 +906,202 @@ export function AdminSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="security" className="space-y-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{encryptionStatus && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status Overview */}
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="p-3 border rounded bg-card">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{encryptionStatus.encryption?.enabled ? (
|
||||||
|
<Lock className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Key className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
|
||||||
|
<div className={`text-xs ${
|
||||||
|
encryptionStatus.encryption?.enabled ? 'text-green-500' : 'text-yellow-500'
|
||||||
|
}`}>
|
||||||
|
{encryptionStatus.encryption?.enabled ? t("admin.enabled") : t("admin.disabled")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border rounded bg-card">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t("admin.keyProtection")}</div>
|
||||||
|
<div className={`text-xs ${
|
||||||
|
encryptionStatus.encryption?.key?.kekProtected ? 'text-green-500' : 'text-yellow-500'
|
||||||
|
}`}>
|
||||||
|
{encryptionStatus.encryption?.key?.kekProtected ? t("admin.active") : t("admin.legacy")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border rounded bg-card">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t("admin.dataStatus")}</div>
|
||||||
|
<div className={`text-xs ${
|
||||||
|
encryptionStatus.migration?.migrationCompleted
|
||||||
|
? 'text-green-500'
|
||||||
|
: encryptionStatus.migration?.migrationRequired
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{encryptionStatus.migration?.migrationCompleted
|
||||||
|
? t("admin.encrypted")
|
||||||
|
: encryptionStatus.migration?.migrationRequired
|
||||||
|
? t("admin.needsMigration")
|
||||||
|
: t("admin.ready")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{!encryptionStatus.encryption?.key?.hasKey ? (
|
||||||
|
<div className="p-4 border rounded bg-card">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-blue-500" />
|
||||||
|
<h4 className="font-medium">{t("admin.initializeEncryption")}</h4>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleInitializeEncryption}
|
||||||
|
disabled={encryptionLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{encryptionLoading ? t("admin.initializing") : t("admin.initialize")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{encryptionStatus.migration?.migrationRequired && (
|
||||||
|
<div className="p-4 border rounded bg-card">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-yellow-500" />
|
||||||
|
<h4 className="font-medium">{t("admin.migrateData")}</h4>
|
||||||
|
</div>
|
||||||
|
{migrationProgress && (
|
||||||
|
<div className="text-sm text-blue-600">{migrationProgress}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMigrateData(true)}
|
||||||
|
disabled={migrationLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("admin.test")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMigrateData(false)}
|
||||||
|
disabled={migrationLoading}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{migrationLoading ? t("admin.migrating") : t("admin.migrate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 border rounded bg-card">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
|
<h4 className="font-medium">{t("admin.backup")}</h4>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateBackup}
|
||||||
|
disabled={backupLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{backupLoading ? t("admin.creatingBackup") : t("admin.createBackup")}
|
||||||
|
</Button>
|
||||||
|
{backupPath && (
|
||||||
|
<div className="p-2 bg-muted rounded border">
|
||||||
|
<div className="text-xs font-mono break-all">{backupPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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.exportImport")}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleExportDatabase}
|
||||||
|
disabled={exportLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||||
|
</Button>
|
||||||
|
{exportPath && (
|
||||||
|
<div className="p-2 bg-muted rounded border">
|
||||||
|
<div className="text-xs font-mono break-all">{exportPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".sqlite,.termix-export.sqlite,.db"
|
||||||
|
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||||
|
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleImportDatabase}
|
||||||
|
disabled={importLoading || !importFile}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!encryptionStatus && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-muted-foreground">{t("admin.loadingEncryptionStatus")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
updateCredential,
|
updateCredential,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getCredentialDetails,
|
getCredentialDetails,
|
||||||
|
detectKeyType,
|
||||||
|
detectPublicKeyType,
|
||||||
|
generatePublicKeyFromPrivate,
|
||||||
|
generateKeyPair,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -42,9 +46,14 @@ export function CredentialEditor({
|
|||||||
useState<Credential | null>(null);
|
useState<Credential | null>(null);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||||
"upload",
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -101,6 +110,7 @@ export function CredentialEditor({
|
|||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.any().optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z
|
keyType: z
|
||||||
.enum([
|
.enum([
|
||||||
@@ -149,6 +159,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
},
|
},
|
||||||
@@ -169,6 +180,7 @@ export function CredentialEditor({
|
|||||||
username: fullCredentialDetails.username || "",
|
username: fullCredentialDetails.username || "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
};
|
};
|
||||||
@@ -176,7 +188,8 @@ export function CredentialEditor({
|
|||||||
if (defaultAuthType === "password") {
|
if (defaultAuthType === "password") {
|
||||||
formData.password = fullCredentialDetails.password || "";
|
formData.password = fullCredentialDetails.password || "";
|
||||||
} else if (defaultAuthType === "key") {
|
} else if (defaultAuthType === "key") {
|
||||||
formData.key = "existing_key";
|
formData.key = fullCredentialDetails.key || "";
|
||||||
|
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||||
formData.keyType =
|
formData.keyType =
|
||||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||||
@@ -196,6 +209,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
});
|
});
|
||||||
@@ -203,6 +217,104 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (keyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(keyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (publicKeyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Detect key type function
|
||||||
|
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');
|
||||||
|
console.warn('Key detection failed:', result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDetectedKeyType('error');
|
||||||
|
console.error('Key type detection error:', error);
|
||||||
|
} finally {
|
||||||
|
setKeyDetectionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced key type detection
|
||||||
|
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||||||
|
if (keyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(keyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
keyDetectionTimeoutRef.current = setTimeout(() => {
|
||||||
|
handleKeyTypeDetection(keyValue, keyPassword);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect public key type function
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced public key type detection
|
||||||
|
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': 'Invalid Key',
|
||||||
|
'error': 'Detection Error',
|
||||||
|
'unknown': 'Unknown'
|
||||||
|
};
|
||||||
|
return keyTypeMap[keyType] || keyType;
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
if (!data.name || data.name.trim() === "") {
|
if (!data.name || data.name.trim() === "") {
|
||||||
@@ -221,20 +333,15 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
submitData.password = null;
|
submitData.password = null;
|
||||||
submitData.key = null;
|
submitData.key = null;
|
||||||
|
submitData.publicKey = null;
|
||||||
submitData.keyPassword = null;
|
submitData.keyPassword = null;
|
||||||
submitData.keyType = null;
|
submitData.keyType = null;
|
||||||
|
|
||||||
if (data.authType === "password") {
|
if (data.authType === "password") {
|
||||||
submitData.password = data.password;
|
submitData.password = data.password;
|
||||||
} else if (data.authType === "key") {
|
} 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.keyPassword = data.keyPassword;
|
||||||
submitData.keyType = data.keyType;
|
submitData.keyType = data.keyType;
|
||||||
}
|
}
|
||||||
@@ -259,11 +366,17 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Credential save error:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
toast.error(t("credentials.failedToSaveCredential"));
|
toast.error(t("credentials.failedToSaveCredential"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState("");
|
||||||
|
|
||||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -305,38 +418,6 @@ export function CredentialEditor({
|
|||||||
};
|
};
|
||||||
}, [folderDropdownOpen]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -589,154 +670,162 @@ export function CredentialEditor({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="key">
|
<TabsContent value="key">
|
||||||
<Tabs
|
<div className="mt-4">
|
||||||
value={keyInputMethod}
|
{/* Generate Key Pair Buttons */}
|
||||||
onValueChange={(value) => {
|
<div className="mb-4 p-4 bg-muted/20 border border-muted rounded-md">
|
||||||
setKeyInputMethod(value as "upload" | "paste");
|
<FormLabel className="mb-3 font-bold block">
|
||||||
if (value === "upload") {
|
{t("credentials.generateKeyPair")}
|
||||||
form.setValue("key", null);
|
</FormLabel>
|
||||||
|
|
||||||
|
{/* Key Generation Passphrase Input */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<FormLabel className="text-sm mb-2 block">
|
||||||
|
{t("credentials.keyPassword")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
<PasswordInput
|
||||||
|
placeholder={t("placeholders.keyPassword")}
|
||||||
|
value={keyGenerationPassphrase}
|
||||||
|
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.keyPassphraseOptional")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.setValue("key", result.privateKey);
|
||||||
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
debouncedPublicKeyDetection(result.publicKey);
|
||||||
|
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
|
||||||
} else {
|
} else {
|
||||||
form.setValue("key", "");
|
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate Ed25519 key pair:', error);
|
||||||
|
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
{t("credentials.generateEd25519")}
|
||||||
<TabsTrigger value="upload">
|
</Button>
|
||||||
{t("hosts.uploadFile")}
|
<Button
|
||||||
</TabsTrigger>
|
type="button"
|
||||||
<TabsTrigger value="paste">
|
variant="outline"
|
||||||
{t("hosts.pasteKey")}
|
size="sm"
|
||||||
</TabsTrigger>
|
onClick={async () => {
|
||||||
</TabsList>
|
try {
|
||||||
<TabsContent value="upload" className="mt-4">
|
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.setValue("key", result.privateKey);
|
||||||
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
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 result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.setValue("key", result.privateKey);
|
||||||
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
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 className="text-xs text-muted-foreground mt-2">
|
||||||
|
{t("credentials.generateKeyPairNote")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 items-start">
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="key"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mb-4">
|
<FormItem className="mb-4 flex flex-col">
|
||||||
<FormLabel>
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
{t("credentials.sshPrivateKey")}
|
{t("credentials.sshPrivateKey")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<div className="mb-2">
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block w-full">
|
||||||
<input
|
<input
|
||||||
id="key-upload"
|
id="key-upload"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pem,.key,.txt,.ppk"
|
accept="*,.pem,.key,.txt,.ppk"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0];
|
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"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start text-left"
|
className="w-full justify-start text-left"
|
||||||
>
|
>
|
||||||
<span
|
<span className="truncate">
|
||||||
className="truncate"
|
{t("credentials.uploadPrivateKeyFile")}
|
||||||
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>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
@@ -748,15 +837,138 @@ export function CredentialEditor({
|
|||||||
? field.value
|
? field.value
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
field.onChange(e.target.value)
|
field.onChange(e.target.value);
|
||||||
}
|
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</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>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4 flex flex-col">
|
||||||
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
<div className="mb-2 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) {
|
||||||
|
// Set the generated public key
|
||||||
|
field.onChange(result.publicKey);
|
||||||
|
// Trigger public key detection
|
||||||
|
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>
|
||||||
|
<textarea
|
||||||
|
placeholder={t(
|
||||||
|
"placeholders.pastePublicKey",
|
||||||
|
)}
|
||||||
|
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"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedPublicKeyDetection(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
|
</div>
|
||||||
|
{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-4 mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="keyPassword"
|
name="keyPassword"
|
||||||
@@ -774,61 +986,8 @@ export function CredentialEditor({
|
|||||||
</FormItem>
|
</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>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} 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 {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -29,12 +44,17 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
|
Upload,
|
||||||
|
Server,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
deleteCredential,
|
deleteCredential,
|
||||||
updateCredential,
|
updateCredential,
|
||||||
renameCredentialFolder,
|
renameCredentialFolder,
|
||||||
|
deployCredentialToHost,
|
||||||
|
getSSHHosts,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -65,12 +85,27 @@ export function CredentialsManager({
|
|||||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
const [editingFolderName, setEditingFolderName] = useState("");
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
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 dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCredentials();
|
fetchCredentials();
|
||||||
|
fetchHosts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
setAvailableHosts(hosts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch hosts:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCredentials = async () => {
|
const fetchCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -90,6 +125,49 @@ 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("");
|
||||||
|
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) => {
|
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
||||||
@@ -577,6 +655,26 @@ export function CredentialsManager({
|
|||||||
<p>Edit credential</p>
|
<p>Edit credential</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -687,6 +785,145 @@ export function CredentialsManager({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||||
|
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
|
||||||
|
<SheetHeader className="space-y-6 pb-8">
|
||||||
|
<SheetTitle className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Upload className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xl font-semibold">Deploy SSH Key</div>
|
||||||
|
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
Deploy public key to target server
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Credential Information Card */}
|
||||||
|
{deployingCredential && (
|
||||||
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
||||||
|
<Key className="h-4 w-4 mr-2 text-zinc-500" />
|
||||||
|
Source Credential
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.name || deployingCredential.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.keyType || 'SSH Key'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target Host Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
||||||
|
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
||||||
|
Target Host
|
||||||
|
</label>
|
||||||
|
<Select value={selectedHostId} onValueChange={setSelectedHostId}>
|
||||||
|
<SelectTrigger className="h-12 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<SelectValue placeholder="Choose a host to deploy to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableHosts.map((host) => (
|
||||||
|
<SelectItem key={host.id} value={host.id.toString()}>
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Server className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{host.username}@{host.ip}:{host.port}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Information Note */}
|
||||||
|
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-medium mb-1">Deployment Process</p>
|
||||||
|
<p className="text-blue-700 dark:text-blue-300">
|
||||||
|
This will safely add the public key to the target host's ~/.ssh/authorized_keys file
|
||||||
|
without overwriting existing keys. The operation is reversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-8 flex space-x-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeployDialog(false)}
|
||||||
|
disabled={deployLoading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
Deploying...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Deploy SSH Key
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React from "react";
|
||||||
import { FileManagerLeftSidebar } from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
import { FileManagerModern } from "@/ui/Desktop/Apps/File Manager/FileManagerModern.tsx";
|
||||||
import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
import type { SSHHost } from "../../../types/index.js";
|
||||||
import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
|
|
||||||
import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
|
||||||
import { cn } from "@/lib/utils.ts";
|
|
||||||
import { Save, RefreshCw, Settings, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
getFileManagerRecent,
|
|
||||||
getFileManagerPinned,
|
|
||||||
getFileManagerShortcuts,
|
|
||||||
addFileManagerRecent,
|
|
||||||
removeFileManagerRecent,
|
|
||||||
addFileManagerPinned,
|
|
||||||
removeFileManagerPinned,
|
|
||||||
addFileManagerShortcut,
|
|
||||||
removeFileManagerShortcut,
|
|
||||||
readSSHFile,
|
|
||||||
writeSSHFile,
|
|
||||||
getSSHStatus,
|
|
||||||
connectSSH,
|
|
||||||
} from "@/ui/main-axios.ts";
|
|
||||||
import type { SSHHost, Tab } from "../../../types/index.js";
|
|
||||||
|
|
||||||
export function FileManager({
|
export function FileManager({
|
||||||
onSelectView,
|
|
||||||
initialHost = null,
|
initialHost = null,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
@@ -36,678 +11,10 @@ export function FileManager({
|
|||||||
initialHost?: SSHHost | null;
|
initialHost?: SSHHost | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
|
||||||
const [activeTab, setActiveTab] = useState<string | number>("home");
|
|
||||||
const [recent, setRecent] = useState<any[]>([]);
|
|
||||||
const [pinned, setPinned] = useState<any[]>([]);
|
|
||||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const [showOperations, setShowOperations] = useState(false);
|
|
||||||
const [currentPath, setCurrentPath] = useState("/");
|
|
||||||
|
|
||||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
|
||||||
|
|
||||||
const sidebarRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
|
||||||
setCurrentHost(initialHost);
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const path = initialHost.defaultPath || "/";
|
|
||||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
|
||||||
sidebarRef.current.openFolder(initialHost, path);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [initialHost]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
} else {
|
|
||||||
setRecent([]);
|
|
||||||
setPinned([]);
|
|
||||||
setShortcuts([]);
|
|
||||||
}
|
|
||||||
}, [currentHost]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === "home" && currentHost) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchHomeData();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [activeTab, currentHost]);
|
|
||||||
|
|
||||||
async function fetchHomeData() {
|
|
||||||
if (!currentHost) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const homeDataPromise = Promise.all([
|
|
||||||
getFileManagerRecent(currentHost.id),
|
|
||||||
getFileManagerPinned(currentHost.id),
|
|
||||||
getFileManagerShortcuts(currentHost.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error(t("fileManager.fetchHomeDataTimeout"))),
|
|
||||||
15000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [recentRes, pinnedRes, shortcutsRes] = (await Promise.race([
|
|
||||||
homeDataPromise,
|
|
||||||
timeoutPromise,
|
|
||||||
])) as [any, any, any];
|
|
||||||
|
|
||||||
const recentWithPinnedStatus = (recentRes || []).map((file) => ({
|
|
||||||
...file,
|
|
||||||
type: "file",
|
|
||||||
isPinned: (pinnedRes || []).some(
|
|
||||||
(pinnedFile) =>
|
|
||||||
pinnedFile.path === file.path && pinnedFile.name === file.name,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pinnedWithType = (pinnedRes || []).map((file) => ({
|
|
||||||
...file,
|
|
||||||
type: "file",
|
|
||||||
}));
|
|
||||||
|
|
||||||
setRecent(recentWithPinnedStatus);
|
|
||||||
setPinned(pinnedWithType);
|
|
||||||
setShortcuts(
|
|
||||||
(shortcutsRes || []).map((shortcut) => ({
|
|
||||||
...shortcut,
|
|
||||||
type: "directory",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
toast.error(t("fileManager.failedToFetchHomeData"));
|
|
||||||
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
|
||||||
if (typeof err === "object" && err !== null && "response" in err) {
|
|
||||||
const axiosErr = err as any;
|
|
||||||
if (axiosErr.response?.status === 403) {
|
|
||||||
return `${t("fileManager.permissionDenied")}. ${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
} else if (axiosErr.response?.status === 500) {
|
|
||||||
const backendError =
|
|
||||||
axiosErr.response?.data?.error ||
|
|
||||||
t("fileManager.internalServerError");
|
|
||||||
return `${t("fileManager.serverError")} (500): ${backendError}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
} else if (axiosErr.response?.data?.error) {
|
|
||||||
const backendError = axiosErr.response.data.error;
|
|
||||||
return `${axiosErr.response?.status ? `${t("fileManager.error")} ${axiosErr.response.status}: ` : ""}${backendError}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
} else {
|
|
||||||
return `${t("fileManager.requestFailed")} ${axiosErr.response?.status || t("fileManager.unknown")}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
return `${err.message}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
} else {
|
|
||||||
return `${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenFile = async (file: any) => {
|
|
||||||
const tabId = file.path;
|
|
||||||
|
|
||||||
if (!tabs.find((t) => t.id === tabId)) {
|
|
||||||
const currentSshSessionId = currentHost?.id.toString();
|
|
||||||
|
|
||||||
setTabs([
|
|
||||||
...tabs,
|
|
||||||
{
|
|
||||||
id: tabId,
|
|
||||||
title: file.name,
|
|
||||||
fileName: file.name,
|
|
||||||
content: "",
|
|
||||||
filePath: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentSshSessionId,
|
|
||||||
loading: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
try {
|
|
||||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
|
||||||
setTabs((tabs) =>
|
|
||||||
tabs.map((t) =>
|
|
||||||
t.id === tabId
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
content: res.content,
|
|
||||||
loading: false,
|
|
||||||
error: undefined,
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await addFileManagerRecent({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentSshSessionId,
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = formatErrorMessage(
|
|
||||||
err,
|
|
||||||
t("fileManager.cannotReadFile"),
|
|
||||||
);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
setTabs((tabs) =>
|
|
||||||
tabs.map((t) => (t.id === tabId ? { ...t, loading: false } : t)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setActiveTab(tabId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveRecent = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerRecent({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePinFile = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await addFileManagerPinned({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnpinFile = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerPinned({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenShortcut = async (shortcut: any) => {
|
|
||||||
if (sidebarRef.current?.isOpeningShortcut) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
|
||||||
try {
|
|
||||||
sidebarRef.current.isOpeningShortcut = true;
|
|
||||||
|
|
||||||
const normalizedPath = shortcut.path.startsWith("/")
|
|
||||||
? shortcut.path
|
|
||||||
: `/${shortcut.path}`;
|
|
||||||
|
|
||||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
|
||||||
} catch (err) {
|
|
||||||
} finally {
|
|
||||||
if (sidebarRef.current) {
|
|
||||||
sidebarRef.current.isOpeningShortcut = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddShortcut = async (folderPath: string) => {
|
|
||||||
try {
|
|
||||||
const name = folderPath.split("/").pop() || folderPath;
|
|
||||||
await addFileManagerShortcut({
|
|
||||||
name,
|
|
||||||
path: folderPath,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentHost?.id.toString(),
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveShortcut = async (shortcut: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerShortcut({
|
|
||||||
name: shortcut.name,
|
|
||||||
path: shortcut.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentHost?.id.toString(),
|
|
||||||
hostId: currentHost?.id,
|
|
||||||
});
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeTab = (tabId: string | number) => {
|
|
||||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
|
||||||
const newTabs = tabs.filter((t) => t.id !== tabId);
|
|
||||||
setTabs(newTabs);
|
|
||||||
if (activeTab === tabId) {
|
|
||||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
|
||||||
else setActiveTab("home");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTabContent = (tabId: string | number, content: string) => {
|
|
||||||
setTabs((tabs) =>
|
|
||||||
tabs.map((t) =>
|
|
||||||
t.id === tabId
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
content,
|
|
||||||
dirty: true,
|
|
||||||
error: undefined,
|
|
||||||
success: undefined,
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (tab: Tab) => {
|
|
||||||
if (isSaving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!tab.sshSessionId) {
|
|
||||||
throw new Error(t("fileManager.noSshSessionId"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tab.filePath) {
|
|
||||||
throw new Error(t("fileManager.noFilePath"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentHost?.id) {
|
|
||||||
throw new Error(t("fileManager.noCurrentHost"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
|
||||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error(t("fileManager.sshStatusCheckTimeout"))),
|
|
||||||
10000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = (await Promise.race([
|
|
||||||
statusPromise,
|
|
||||||
statusTimeoutPromise,
|
|
||||||
])) as { connected: boolean };
|
|
||||||
|
|
||||||
if (!status.connected) {
|
|
||||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
|
||||||
hostId: currentHost.id,
|
|
||||||
ip: currentHost.ip,
|
|
||||||
port: currentHost.port,
|
|
||||||
username: currentHost.username,
|
|
||||||
password: currentHost.password,
|
|
||||||
sshKey: currentHost.key,
|
|
||||||
keyPassword: currentHost.keyPassword,
|
|
||||||
authType: currentHost.authType,
|
|
||||||
credentialId: currentHost.credentialId,
|
|
||||||
userId: currentHost.userId,
|
|
||||||
});
|
|
||||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error(t("fileManager.sshReconnectionTimeout"))),
|
|
||||||
15000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
|
||||||
}
|
|
||||||
} catch (statusErr) {}
|
|
||||||
|
|
||||||
const savePromise = writeSSHFile(
|
|
||||||
tab.sshSessionId,
|
|
||||||
tab.filePath,
|
|
||||||
tab.content,
|
|
||||||
);
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error(t("fileManager.saveOperationTimeout")));
|
|
||||||
}, 30000),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
|
||||||
setTabs((tabs) =>
|
|
||||||
tabs.map((t) =>
|
|
||||||
t.id === tab.id
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
loading: false,
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.toast) {
|
|
||||||
toast[result.toast.type](result.toast.message);
|
|
||||||
} else {
|
|
||||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.allSettled([
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await addFileManagerRecent({
|
|
||||||
name: tab.fileName,
|
|
||||||
path: tab.filePath,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: tab.sshSessionId,
|
|
||||||
hostId: currentHost.id,
|
|
||||||
});
|
|
||||||
} catch (recentErr) {}
|
|
||||||
})(),
|
|
||||||
]).then(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
let errorMessage = formatErrorMessage(
|
|
||||||
err,
|
|
||||||
t("fileManager.cannotSaveFile"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorMessage.includes("timed out") ||
|
|
||||||
errorMessage.includes("timeout")
|
|
||||||
) {
|
|
||||||
errorMessage = t("fileManager.saveTimeout");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(`${t("fileManager.failedToSaveFile")}: ${errorMessage}`);
|
|
||||||
setTabs((tabs) =>
|
|
||||||
tabs.map((t) =>
|
|
||||||
t.id === tab.id
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
loading: false,
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHostChange = (_host: SSHHost | null) => {};
|
|
||||||
|
|
||||||
const handleOperationComplete = () => {
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuccess = (message: string) => {
|
|
||||||
toast.success(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: string) => {
|
|
||||||
toast.error(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCurrentPath = (newPath: string) => {
|
|
||||||
setCurrentPath(newPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromSidebar = (item: any) => {
|
|
||||||
setDeletingItem(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const performDelete = async (item: any) => {
|
|
||||||
if (!currentHost?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
|
|
||||||
const response = await deleteSSHItem(
|
|
||||||
currentHost.id.toString(),
|
|
||||||
item.path,
|
|
||||||
item.type === "directory",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`${item.type === "directory" ? t("fileManager.folder") : t("fileManager.file")} ${t("fileManager.deletedSuccessfully")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeletingItem(null);
|
|
||||||
handleOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
handleError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentHost) {
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
<FileManagerModern
|
||||||
<div className="absolute top-0 left-0 w-64 h-full z-[20]">
|
initialHost={initialHost}
|
||||||
<FileManagerLeftSidebar
|
onClose={onClose}
|
||||||
onSelectView={onSelectView || (() => {})}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
tabs={tabs}
|
|
||||||
ref={sidebarRef}
|
|
||||||
host={initialHost as SSHHost}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onPathChange={updateCurrentPath}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">
|
|
||||||
{t("fileManager.connectToServer")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t("fileManager.selectServerToEdit")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
|
||||||
<div className="absolute top-0 left-0 w-64 h-full z-[20]">
|
|
||||||
<FileManagerLeftSidebar
|
|
||||||
onSelectView={onSelectView || (() => {})}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
tabs={tabs}
|
|
||||||
ref={sidebarRef}
|
|
||||||
host={currentHost as SSHHost}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onPathChange={updateCurrentPath}
|
|
||||||
onDeleteItem={handleDeleteFromSidebar}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 left-64 right-0 h-[50px] z-[30]">
|
|
||||||
<div className="flex items-center w-full bg-dark-bg border-b-2 border-dark-border h-[50px] relative">
|
|
||||||
<div className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
|
||||||
<FIleManagerTopNavbar
|
|
||||||
tabs={tabs.map((t) => ({ id: t.id, title: t.title }))}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
closeTab={closeTab}
|
|
||||||
onHomeClick={() => {
|
|
||||||
setActiveTab("home");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-2 flex-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowOperations(!showOperations)}
|
|
||||||
className={cn(
|
|
||||||
"w-[30px] h-[30px]",
|
|
||||||
showOperations ? "bg-dark-hover border-dark-border-hover" : "",
|
|
||||||
)}
|
|
||||||
title={t("fileManager.fileOperations")}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="p-0.25 w-px h-[30px] bg-dark-border"></div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const tab = tabs.find((t) => t.id === activeTab);
|
|
||||||
if (tab && !isSaving) handleSave(tab);
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
activeTab === "home" ||
|
|
||||||
!tabs.find((t) => t.id === activeTab)?.dirty ||
|
|
||||||
isSaving
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"w-[30px] h-[30px]",
|
|
||||||
activeTab === "home" ||
|
|
||||||
!tabs.find((t) => t.id === activeTab)?.dirty ||
|
|
||||||
isSaving
|
|
||||||
? "opacity-60 cursor-not-allowed"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="flex-1">
|
|
||||||
{activeTab === "home" ? (
|
|
||||||
<FileManagerHomeView
|
|
||||||
recent={recent}
|
|
||||||
pinned={pinned}
|
|
||||||
shortcuts={shortcuts}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
onRemoveRecent={handleRemoveRecent}
|
|
||||||
onPinFile={handlePinFile}
|
|
||||||
onUnpinFile={handleUnpinFile}
|
|
||||||
onOpenShortcut={handleOpenShortcut}
|
|
||||||
onRemoveShortcut={handleRemoveShortcut}
|
|
||||||
onAddShortcut={handleAddShortcut}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const tab = tabs.find((t) => t.id === activeTab);
|
|
||||||
if (!tab) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<FileManagerFileEditor
|
|
||||||
content={tab.content}
|
|
||||||
fileName={tab.fileName}
|
|
||||||
onContentChange={(content) =>
|
|
||||||
setTabContent(tab.id, content)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showOperations && (
|
|
||||||
<div className="w-80 border-l-2 border-dark-border bg-dark-bg-darkest overflow-y-auto">
|
|
||||||
<FileManagerOperations
|
|
||||||
currentPath={currentPath}
|
|
||||||
sshSessionId={currentHost?.id.toString() || null}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deletingItem && (
|
|
||||||
<div className="fixed inset-0 z-[99999]">
|
|
||||||
<div className="absolute inset-0 bg-black/60"></div>
|
|
||||||
|
|
||||||
<div className="relative h-full flex items-center justify-center">
|
|
||||||
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
||||||
<Trash2 className="w-5 h-5 text-red-400" />
|
|
||||||
{t("fileManager.confirmDelete")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-white mb-4">
|
|
||||||
{t("fileManager.confirmDeleteMessage", {
|
|
||||||
name: deletingItem.name,
|
|
||||||
})}
|
|
||||||
{deletingItem.type === "directory" &&
|
|
||||||
` ${t("fileManager.deleteDirectoryWarning")}`}
|
|
||||||
</p>
|
|
||||||
<p className="text-red-400 text-sm mb-6">
|
|
||||||
{t("fileManager.actionCannotBeUndone")}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => performDelete(deletingItem)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{t("common.delete")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingItem(null)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 右键点击关闭菜单(Windows行为)
|
||||||
|
const handleRightClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 键盘支持
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 窗口失焦关闭菜单
|
||||||
|
const handleBlur = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 滚动时关闭菜单(Windows行为)
|
||||||
|
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); // 50ms延迟,确保不会捕获到触发菜单的点击
|
||||||
|
|
||||||
|
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+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 (hasFiles && onDragToDesktop) {
|
||||||
|
const isModernBrowser = 'showSaveFilePicker' in window;
|
||||||
|
menuItems.push({
|
||||||
|
icon: <ExternalLink className="w-4 h-4" />,
|
||||||
|
label: isMultipleFiles
|
||||||
|
? t("fileManager.saveFilesToSystem", { count: files.length })
|
||||||
|
: t("fileManager.saveToSystem"),
|
||||||
|
action: () => onDragToDesktop(),
|
||||||
|
shortcut: isModernBrowser ? t("fileManager.selectLocationToSave") : t("fileManager.downloadToDefaultLocation")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIN/UNPIN 功能 - 仅对单个文件显示
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "F2"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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+T"
|
||||||
|
});
|
||||||
|
|
||||||
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onUpload) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Upload className="w-4 h-4" />,
|
||||||
|
label: t("fileManager.uploadFile"),
|
||||||
|
action: onUpload,
|
||||||
|
shortcut: "Ctrl+U"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
|
|
||||||
|
if (onRefresh) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <RefreshCw className="w-4 h-4" />,
|
||||||
|
label: t("fileManager.refresh"),
|
||||||
|
action: onRefresh,
|
||||||
|
shortcut: "F5"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClipboard && onPaste) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Clipboard className="w-4 h-4" />,
|
||||||
|
label: t("fileManager.paste"),
|
||||||
|
action: onPaste,
|
||||||
|
shortcut: "Ctrl+V"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 透明遮罩层用于捕获点击事件 */}
|
||||||
|
<div className="fixed inset-0 z-40" />
|
||||||
|
|
||||||
|
{/* 菜单本体 */}
|
||||||
|
<div
|
||||||
|
data-context-menu
|
||||||
|
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl py-1 min-w-[180px] max-w-[250px] z-50"
|
||||||
|
style={{
|
||||||
|
left: menuPosition.x,
|
||||||
|
top: menuPosition.y
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item, index) => {
|
||||||
|
if (item.separator) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`separator-${index}`}
|
||||||
|
className="border-t border-dark-border my-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
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">
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.shortcut}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
File,
|
File,
|
||||||
|
FileSymlink,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Pin,
|
Pin,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
removeFileManagerPinned,
|
removeFileManagerPinned,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH,
|
connectSSH,
|
||||||
|
identifySSHSymlink,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SSHHost } from "../../../types/index.js";
|
import type { SSHHost } from "../../../types/index.js";
|
||||||
|
|
||||||
@@ -339,7 +341,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
try {
|
try {
|
||||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||||
toast.success(
|
toast.success(
|
||||||
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
||||||
);
|
);
|
||||||
setRenamingItem(null);
|
setRenamingItem(null);
|
||||||
if (onOperationComplete) {
|
if (onOperationComplete) {
|
||||||
@@ -375,6 +377,74 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
onPathChange?.(newPath);
|
onPathChange?.(newPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle symlink resolution
|
||||||
|
const handleSymlinkClick = async (item: any) => {
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract just the symlink path (before the " -> " if present)
|
||||||
|
const symlinkPath = item.path.includes(" -> ")
|
||||||
|
? item.path.split(" -> ")[0]
|
||||||
|
: item.path;
|
||||||
|
|
||||||
|
let currentSessionId = sshSessionId;
|
||||||
|
|
||||||
|
// Check SSH connection status and reconnect if needed
|
||||||
|
if (currentSessionId) {
|
||||||
|
try {
|
||||||
|
const status = await getSSHStatus(currentSessionId);
|
||||||
|
if (!status.connected) {
|
||||||
|
const newSessionId = await connectToSSH(host);
|
||||||
|
if (newSessionId) {
|
||||||
|
setSshSessionId(newSessionId);
|
||||||
|
currentSessionId = newSessionId;
|
||||||
|
} else {
|
||||||
|
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (sessionErr) {
|
||||||
|
const newSessionId = await connectToSSH(host);
|
||||||
|
if (newSessionId) {
|
||||||
|
setSshSessionId(newSessionId);
|
||||||
|
currentSessionId = newSessionId;
|
||||||
|
} else {
|
||||||
|
throw sessionErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No session ID, try to connect
|
||||||
|
const newSessionId = await connectToSSH(host);
|
||||||
|
if (newSessionId) {
|
||||||
|
setSshSessionId(newSessionId);
|
||||||
|
currentSessionId = newSessionId;
|
||||||
|
} else {
|
||||||
|
throw new Error(t("fileManager.failedToConnectSSH"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath);
|
||||||
|
|
||||||
|
if (symlinkInfo.type === "directory") {
|
||||||
|
// If symlink points to a directory, navigate to it
|
||||||
|
handlePathChange(symlinkInfo.target);
|
||||||
|
} else if (symlinkInfo.type === "file") {
|
||||||
|
// If symlink points to a file, open it as a file
|
||||||
|
onOpenFile({
|
||||||
|
name: item.name,
|
||||||
|
path: symlinkInfo.target, // Use the target path, not the symlink path
|
||||||
|
isSSH: item.isSSH,
|
||||||
|
sshSessionId: currentSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
t("fileManager.failedToResolveSymlink"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
<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 flex-col flex-grow min-h-0">
|
||||||
@@ -456,6 +526,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{item.type === "directory" ? (
|
{item.type === "directory" ? (
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||||
|
) : item.type === "link" ? (
|
||||||
|
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -496,6 +568,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
!isOpen &&
|
!isOpen &&
|
||||||
(item.type === "directory"
|
(item.type === "directory"
|
||||||
? handlePathChange(item.path)
|
? handlePathChange(item.path)
|
||||||
|
: item.type === "link"
|
||||||
|
? handleSymlinkClick(item)
|
||||||
: onOpenFile({
|
: onOpenFile({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
@@ -506,6 +580,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
>
|
>
|
||||||
{item.type === "directory" ? (
|
{item.type === "directory" ? (
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||||
|
) : item.type === "link" ? (
|
||||||
|
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -514,7 +590,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{item.type === "file" && (
|
{(item.type === "file") && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Card } from "@/components/ui/card.tsx";
|
import { Card } from "@/components/ui/card.tsx";
|
||||||
import { Folder, File, Trash2, Pin } from "lucide-react";
|
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SSHConnection {
|
interface SSHConnection {
|
||||||
@@ -32,6 +32,7 @@ interface FileManagerLeftSidebarVileViewerProps {
|
|||||||
onOpenFile: (file: FileItem) => void;
|
onOpenFile: (file: FileItem) => void;
|
||||||
onOpenFolder: (folder: FileItem) => void;
|
onOpenFolder: (folder: FileItem) => void;
|
||||||
onStarFile: (file: FileItem) => void;
|
onStarFile: (file: FileItem) => void;
|
||||||
|
onDownloadFile?: (file: FileItem) => void;
|
||||||
onDeleteFile: (file: FileItem) => void;
|
onDeleteFile: (file: FileItem) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -47,6 +48,7 @@ export function FileManagerLeftSidebarFileViewer({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onStarFile,
|
onStarFile,
|
||||||
|
onDownloadFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -104,6 +106,17 @@ export function FileManagerLeftSidebarFileViewer({
|
|||||||
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
{item.type === "file" && onDownloadFile && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onDownloadFile(item)}
|
||||||
|
title={t("fileManager.downloadFile")}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-blue-400" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card.tsx";
|
|||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
|
Download,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -27,12 +28,14 @@ export function FileManagerOperations({
|
|||||||
}: FileManagerOperationsProps) {
|
}: FileManagerOperationsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showDownload, setShowDownload] = useState(false);
|
||||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [showRename, setShowRename] = useState(false);
|
const [showRename, setShowRename] = useState(false);
|
||||||
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [downloadPath, setDownloadPath] = useState("");
|
||||||
const [newFileName, setNewFileName] = useState("");
|
const [newFileName, setNewFileName] = useState("");
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [deletePath, setDeletePath] = useState("");
|
const [deletePath, setDeletePath] = useState("");
|
||||||
@@ -77,7 +80,45 @@ export function FileManagerOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await uploadFile.text();
|
// 读取文件内容 - 支持文本和二进制文件
|
||||||
|
const content = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
|
||||||
|
// 检查文件类型,决定读取方式
|
||||||
|
const isTextFile = uploadFile.type.startsWith('text/') ||
|
||||||
|
uploadFile.type === 'application/json' ||
|
||||||
|
uploadFile.type === 'application/javascript' ||
|
||||||
|
uploadFile.type === 'application/xml' ||
|
||||||
|
uploadFile.name.match(/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i);
|
||||||
|
|
||||||
|
if (isTextFile) {
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result) {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read text file content'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(uploadFile);
|
||||||
|
} else {
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result instanceof ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(reader.result);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read binary file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(uploadFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
|
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
|
||||||
|
|
||||||
const response = await uploadSSHFile(
|
const response = await uploadSSHFile(
|
||||||
@@ -154,6 +195,66 @@ export function FileManagerOperations({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!downloadPath.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { toast } = await import("sonner");
|
||||||
|
const fileName = downloadPath.split('/').pop() || 'download';
|
||||||
|
const loadingToast = toast.loading(
|
||||||
|
t("fileManager.downloadingFile", { name: fileName }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
|
||||||
|
|
||||||
|
const response = await downloadSSHFile(
|
||||||
|
sshSessionId,
|
||||||
|
downloadPath.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
|
||||||
|
if (response?.content) {
|
||||||
|
// Convert base64 to blob and trigger download
|
||||||
|
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' });
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = response.fileName || fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
onSuccess(
|
||||||
|
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onError(t("fileManager.noFileContent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDownload(false);
|
||||||
|
setDownloadPath("");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
onError(
|
||||||
|
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
if (!newFolderName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
@@ -344,7 +445,7 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="p-4 space-y-4">
|
<div ref={containerRef} className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -357,6 +458,18 @@ export function FileManagerOperations({
|
|||||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownload(true)}
|
||||||
|
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||||
|
title={t("fileManager.downloadFile")}
|
||||||
|
>
|
||||||
|
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||||
|
{showTextLabels && (
|
||||||
|
<span className="truncate">{t("fileManager.downloadFile")}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -397,7 +510,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(true)}
|
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"
|
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
|
||||||
title={t("fileManager.deleteItem")}
|
title={t("fileManager.deleteItem")}
|
||||||
>
|
>
|
||||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||||
@@ -516,6 +629,64 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showDownload && (
|
||||||
|
<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">
|
||||||
|
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||||
|
<span className="break-words">
|
||||||
|
{t("fileManager.downloadFile")}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownload(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.filePath")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={downloadPath}
|
||||||
|
onChange={(e) => setDownloadPath(e.target.value)}
|
||||||
|
placeholder={t("placeholders.fullPath")}
|
||||||
|
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!downloadPath.trim() || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? t("fileManager.downloading")
|
||||||
|
: t("fileManager.downloadFile")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDownload(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCreateFile && (
|
{showCreateFile && (
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
<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 items-start justify-between mb-3">
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
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.js";
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 加载目录树(依赖sshSessionId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sshSessionId) {
|
||||||
|
loadDirectoryTree();
|
||||||
|
}
|
||||||
|
}, [sshSessionId]);
|
||||||
|
|
||||||
|
const loadQuickAccessData = async () => {
|
||||||
|
if (!currentHost?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加载最近访问文件(限制5个)
|
||||||
|
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 {
|
||||||
|
// 批量删除所有recent文件
|
||||||
|
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, '/');
|
||||||
|
|
||||||
|
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
|
||||||
|
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);
|
||||||
|
|
||||||
|
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
|
||||||
|
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 px-2 py-1 text-sm cursor-pointer hover:bg-dark-hover rounded",
|
||||||
|
isActive && "bg-primary/20 text-primary",
|
||||||
|
"text-white"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${8 + level * 16}px` }}
|
||||||
|
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-4">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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-0 overflow-y-auto thin-scrollbar p-2 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="border-t border-dark-border pt-4">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 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 py-1 min-w-[160px] z-50"
|
||||||
|
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"
|
||||||
|
onClick={() => {
|
||||||
|
handleRemoveRecentFile(contextMenu.item!);
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{t("fileManager.removeFromRecentFiles")}</span>
|
||||||
|
</button>
|
||||||
|
{recentItems.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-dark-border my-1" />
|
||||||
|
<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"
|
||||||
|
onClick={() => {
|
||||||
|
handleClearAllRecent();
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{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"
|
||||||
|
onClick={() => {
|
||||||
|
handleUnpinFile(contextMenu.item!);
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
<span>{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"
|
||||||
|
onClick={() => {
|
||||||
|
handleRemoveShortcut(contextMenu.item!);
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
<span>{t("fileManager.removeShortcut")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { DiffEditor } from '@monaco-editor/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
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,
|
||||||
|
onDownload1,
|
||||||
|
onDownload2
|
||||||
|
}: DiffViewerProps) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 确保SSH连接有效
|
||||||
|
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('只能对比文件类型的项目');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 确保SSH连接有效
|
||||||
|
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(`文件过大: ${errorData.error}`);
|
||||||
|
} else if (error.message?.includes('connection') || error.message?.includes('established')) {
|
||||||
|
setError(`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`);
|
||||||
|
} else {
|
||||||
|
setError(`加载文件失败: ${error.message || errorData?.error || '未知错误'}`);
|
||||||
|
}
|
||||||
|
} 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(`文件下载成功: ${file.name}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to download file:', error);
|
||||||
|
toast.error(`下载失败: ${error.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件语言类型
|
||||||
|
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">正在加载文件对比...</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" />
|
||||||
|
重新加载
|
||||||
|
</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">对比:</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' ? '并排' : '内联'}
|
||||||
|
</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={`下载 ${file1.name}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-1" />
|
||||||
|
{file1.name}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadFile(file2)}
|
||||||
|
title={`下载 ${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>
|
||||||
|
|
||||||
|
{/* Diff编辑器 */}
|
||||||
|
<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">初始化编辑器...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DraggableWindow } from './DraggableWindow';
|
||||||
|
import { DiffViewer } from './DiffViewer';
|
||||||
|
import { useWindowManager } from './WindowManager';
|
||||||
|
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 { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
|
||||||
|
|
||||||
|
const currentWindow = windows.find(w => w.id === windowId);
|
||||||
|
|
||||||
|
// 窗口操作处理
|
||||||
|
const handleClose = () => {
|
||||||
|
closeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
minimizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
maximizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
focusWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentWindow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableWindow
|
||||||
|
title={`文件对比: ${file1.name} ↔ ${file2.name}`}
|
||||||
|
initialX={initialX}
|
||||||
|
initialY={initialY}
|
||||||
|
initialWidth={1200}
|
||||||
|
initialHeight={700}
|
||||||
|
minWidth={800}
|
||||||
|
minHeight={500}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
isMaximized={currentWindow.isMaximized}
|
||||||
|
zIndex={currentWindow.zIndex}
|
||||||
|
>
|
||||||
|
<DiffViewer
|
||||||
|
file1={file1}
|
||||||
|
file2={file2}
|
||||||
|
sshSessionId={sshSessionId}
|
||||||
|
sshHost={sshHost}
|
||||||
|
/>
|
||||||
|
</DraggableWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Minus, Square, X, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}: DraggableWindowProps) {
|
||||||
|
// 窗口状态
|
||||||
|
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 windowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 处理窗口焦点
|
||||||
|
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;
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(0, Math.min(window.innerWidth - size.width, windowStart.x + deltaX)),
|
||||||
|
y: Math.max(0, Math.min(window.innerHeight - 40, windowStart.y + deltaY)) // 保持标题栏可见
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResizing && !isMaximized) {
|
||||||
|
const deltaX = e.clientX - dragStart.x;
|
||||||
|
const deltaY = e.clientY - dragStart.y;
|
||||||
|
|
||||||
|
let newWidth = size.width;
|
||||||
|
let newHeight = size.height;
|
||||||
|
let newX = position.x;
|
||||||
|
let newY = position.y;
|
||||||
|
|
||||||
|
if (resizeDirection.includes('right')) {
|
||||||
|
newWidth = Math.max(minWidth, windowStart.x + deltaX);
|
||||||
|
}
|
||||||
|
if (resizeDirection.includes('left')) {
|
||||||
|
newWidth = Math.max(minWidth, size.width - deltaX);
|
||||||
|
newX = Math.min(windowStart.x + deltaX, position.x + size.width - minWidth);
|
||||||
|
}
|
||||||
|
if (resizeDirection.includes('bottom')) {
|
||||||
|
newHeight = Math.max(minHeight, windowStart.y + deltaY);
|
||||||
|
}
|
||||||
|
if (resizeDirection.includes('top')) {
|
||||||
|
newHeight = Math.max(minHeight, size.height - deltaY);
|
||||||
|
newY = Math.min(windowStart.y + deltaY, position.y + size.height - minHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize({ width: newWidth, height: newHeight });
|
||||||
|
setPosition({ x: newX, y: newY });
|
||||||
|
}
|
||||||
|
}, [isDragging, isResizing, isMaximized, dragStart, windowStart, 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: size.width, y: size.height });
|
||||||
|
onFocus?.();
|
||||||
|
}, [isMaximized, 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="最小化"
|
||||||
|
>
|
||||||
|
<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 ? "还原" : "最大化"}
|
||||||
|
>
|
||||||
|
{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="关闭"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 窗口内容 */}
|
||||||
|
<div className="flex-1 overflow-auto" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Film,
|
||||||
|
Music,
|
||||||
|
File as FileIcon,
|
||||||
|
Code,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Replace
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SiJavascript,
|
||||||
|
SiTypescript,
|
||||||
|
SiPython,
|
||||||
|
SiOracle,
|
||||||
|
SiCplusplus,
|
||||||
|
SiC,
|
||||||
|
SiDotnet,
|
||||||
|
SiPhp,
|
||||||
|
SiRuby,
|
||||||
|
SiGo,
|
||||||
|
SiRust,
|
||||||
|
SiHtml5,
|
||||||
|
SiCss3,
|
||||||
|
SiSass,
|
||||||
|
SiLess,
|
||||||
|
SiJson,
|
||||||
|
SiXml,
|
||||||
|
SiYaml,
|
||||||
|
SiToml,
|
||||||
|
SiShell,
|
||||||
|
SiVuedotjs,
|
||||||
|
SiSvelte,
|
||||||
|
SiMarkdown,
|
||||||
|
SiGnubash,
|
||||||
|
SiMysql,
|
||||||
|
SiDocker
|
||||||
|
} from 'react-icons/si';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { oneDark } from '@uiw/codemirror-themes';
|
||||||
|
import { languages, loadLanguage } from '@uiw/codemirror-extensions-langs';
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory" | "link";
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: string;
|
||||||
|
permissions?: string;
|
||||||
|
owner?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileViewerProps {
|
||||||
|
file: FileItem;
|
||||||
|
content?: string;
|
||||||
|
savedContent?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isEditable?: boolean;
|
||||||
|
onContentChange?: (content: string) => void;
|
||||||
|
onSave?: (content: string) => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取编程语言的官方图标
|
||||||
|
function getLanguageIcon(filename: string): React.ReactNode {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const baseName = filename.toLowerCase();
|
||||||
|
|
||||||
|
// 特殊文件名处理
|
||||||
|
if (['dockerfile'].includes(baseName)) {
|
||||||
|
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||||||
|
}
|
||||||
|
if (['makefile', 'rakefile', 'gemfile'].includes(baseName)) {
|
||||||
|
return <SiRuby className="w-6 h-6 text-red-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
'js': <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||||
|
'jsx': <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||||
|
'ts': <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||||
|
'tsx': <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||||
|
'py': <SiPython className="w-6 h-6 text-blue-400" />,
|
||||||
|
'java': <SiOracle className="w-6 h-6 text-red-500" />,
|
||||||
|
'cpp': <SiCplusplus className="w-6 h-6 text-blue-600" />,
|
||||||
|
'c': <SiC className="w-6 h-6 text-blue-700" />,
|
||||||
|
'cs': <SiDotnet className="w-6 h-6 text-purple-600" />,
|
||||||
|
'php': <SiPhp className="w-6 h-6 text-indigo-500" />,
|
||||||
|
'rb': <SiRuby className="w-6 h-6 text-red-500" />,
|
||||||
|
'go': <SiGo className="w-6 h-6 text-cyan-500" />,
|
||||||
|
'rs': <SiRust className="w-6 h-6 text-orange-600" />,
|
||||||
|
'html': <SiHtml5 className="w-6 h-6 text-orange-500" />,
|
||||||
|
'css': <SiCss3 className="w-6 h-6 text-blue-500" />,
|
||||||
|
'scss': <SiSass className="w-6 h-6 text-pink-500" />,
|
||||||
|
'sass': <SiSass className="w-6 h-6 text-pink-500" />,
|
||||||
|
'less': <SiLess className="w-6 h-6 text-blue-600" />,
|
||||||
|
'json': <SiJson className="w-6 h-6 text-yellow-500" />,
|
||||||
|
'xml': <SiXml className="w-6 h-6 text-orange-500" />,
|
||||||
|
'yaml': <SiYaml className="w-6 h-6 text-red-400" />,
|
||||||
|
'yml': <SiYaml className="w-6 h-6 text-red-400" />,
|
||||||
|
'toml': <SiToml className="w-6 h-6 text-orange-400" />,
|
||||||
|
'sql': <SiMysql className="w-6 h-6 text-blue-500" />,
|
||||||
|
'sh': <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||||
|
'bash': <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||||
|
'zsh': <SiShell className="w-6 h-6 text-gray-700" />,
|
||||||
|
'vue': <SiVuedotjs className="w-6 h-6 text-green-500" />,
|
||||||
|
'svelte': <SiSvelte className="w-6 h-6 text-orange-500" />,
|
||||||
|
'md': <SiMarkdown className="w-6 h-6 text-gray-600" />,
|
||||||
|
'conf': <SiShell className="w-6 h-6 text-gray-600" />,
|
||||||
|
'ini': <Code className="w-6 h-6 text-gray-600" />
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件类型和图标
|
||||||
|
function getFileType(filename: string): { type: string; icon: React.ReactNode; color: string } {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'];
|
||||||
|
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm'];
|
||||||
|
const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'];
|
||||||
|
const textExts = ['txt', 'readme'];
|
||||||
|
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'html', 'css', 'scss', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte', 'md'];
|
||||||
|
|
||||||
|
if (imageExts.includes(ext)) {
|
||||||
|
return { type: 'image', icon: <ImageIcon className="w-6 h-6" />, color: 'text-green-500' };
|
||||||
|
} else if (videoExts.includes(ext)) {
|
||||||
|
return { type: 'video', icon: <Film className="w-6 h-6" />, color: 'text-purple-500' };
|
||||||
|
} else if (audioExts.includes(ext)) {
|
||||||
|
return { type: 'audio', icon: <Music className="w-6 h-6" />, color: 'text-pink-500' };
|
||||||
|
} else if (textExts.includes(ext)) {
|
||||||
|
return { type: 'text', icon: <FileText className="w-6 h-6" />, color: 'text-blue-500' };
|
||||||
|
} else if (codeExts.includes(ext)) {
|
||||||
|
return { type: 'code', icon: getLanguageIcon(filename), color: 'text-yellow-500' };
|
||||||
|
} else {
|
||||||
|
return { type: 'unknown', icon: <FileIcon className="w-6 h-6" />, color: 'text-gray-500' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取CodeMirror语言扩展
|
||||||
|
function getLanguageExtension(filename: string) {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const baseName = filename.toLowerCase();
|
||||||
|
|
||||||
|
// 特殊文件名处理
|
||||||
|
if (['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(baseName)) {
|
||||||
|
return loadLanguage(baseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据扩展名映射
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
'js': 'javascript',
|
||||||
|
'jsx': 'jsx',
|
||||||
|
'ts': 'typescript',
|
||||||
|
'tsx': 'tsx',
|
||||||
|
'py': 'python',
|
||||||
|
'java': 'java',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'c': 'c',
|
||||||
|
'cs': 'csharp',
|
||||||
|
'php': 'php',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'go': 'go',
|
||||||
|
'rs': 'rust',
|
||||||
|
'html': 'html',
|
||||||
|
'css': 'css',
|
||||||
|
'scss': 'sass',
|
||||||
|
'less': 'less',
|
||||||
|
'json': 'json',
|
||||||
|
'xml': 'xml',
|
||||||
|
'yaml': 'yaml',
|
||||||
|
'yml': 'yaml',
|
||||||
|
'toml': 'toml',
|
||||||
|
'sql': 'sql',
|
||||||
|
'sh': 'shell',
|
||||||
|
'bash': 'shell',
|
||||||
|
'zsh': 'shell',
|
||||||
|
'vue': 'vue',
|
||||||
|
'svelte': 'svelte',
|
||||||
|
'md': 'markdown',
|
||||||
|
'conf': 'shell',
|
||||||
|
'ini': 'properties'
|
||||||
|
};
|
||||||
|
|
||||||
|
const language = langMap[ext];
|
||||||
|
return language ? loadLanguage(language) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
function formatFileSize(bytes?: number): string {
|
||||||
|
if (!bytes) return 'Unknown size';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileViewer({
|
||||||
|
file,
|
||||||
|
content = '',
|
||||||
|
savedContent = '',
|
||||||
|
isLoading = false,
|
||||||
|
isEditable = false,
|
||||||
|
onContentChange,
|
||||||
|
onSave,
|
||||||
|
onDownload
|
||||||
|
}: FileViewerProps) {
|
||||||
|
const [editedContent, setEditedContent] = useState(content);
|
||||||
|
const [originalContent, setOriginalContent] = useState(savedContent || content);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
|
||||||
|
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||||||
|
const [showSearchPanel, setShowSearchPanel] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [replaceText, setReplaceText] = useState('');
|
||||||
|
const [showReplacePanel, setShowReplacePanel] = useState(false);
|
||||||
|
const [searchMatches, setSearchMatches] = useState<{ start: number; end: number }[]>([]);
|
||||||
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
|
||||||
|
|
||||||
|
const fileTypeInfo = getFileType(file.name);
|
||||||
|
|
||||||
|
// 文件大小限制 (1MB for warning, 10MB for hard limit)
|
||||||
|
const WARNING_SIZE = 1024 * 1024; // 1MB
|
||||||
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
// 检查是否应该显示为文本
|
||||||
|
const shouldShowAsText =
|
||||||
|
fileTypeInfo.type === 'text' ||
|
||||||
|
fileTypeInfo.type === 'code' ||
|
||||||
|
(fileTypeInfo.type === 'unknown' && (forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||||
|
|
||||||
|
// 检查文件是否过大
|
||||||
|
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||||||
|
const isTooLarge = file.size && file.size > MAX_SIZE;
|
||||||
|
|
||||||
|
// 同步外部内容更改
|
||||||
|
useEffect(() => {
|
||||||
|
setEditedContent(content);
|
||||||
|
// 只有在savedContent更新时才更新originalContent
|
||||||
|
if (savedContent) {
|
||||||
|
setOriginalContent(savedContent);
|
||||||
|
}
|
||||||
|
setHasChanges(content !== (savedContent || content));
|
||||||
|
|
||||||
|
// 如果是未知文件类型且文件较大,显示警告
|
||||||
|
if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) {
|
||||||
|
setShowLargeFileWarning(true);
|
||||||
|
} else {
|
||||||
|
setShowLargeFileWarning(false);
|
||||||
|
}
|
||||||
|
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||||
|
|
||||||
|
// 处理内容更改
|
||||||
|
const handleContentChange = (newContent: string) => {
|
||||||
|
setEditedContent(newContent);
|
||||||
|
setHasChanges(newContent !== originalContent);
|
||||||
|
onContentChange?.(newContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave?.(editedContent);
|
||||||
|
// 注意:不在这里更新originalContent,因为它会通过savedContent prop更新
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复原文件
|
||||||
|
const handleRevert = () => {
|
||||||
|
setEditedContent(originalContent);
|
||||||
|
setHasChanges(false);
|
||||||
|
onContentChange?.(originalContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索匹配功能
|
||||||
|
const findMatches = (text: string) => {
|
||||||
|
if (!text) {
|
||||||
|
setSearchMatches([]);
|
||||||
|
setCurrentMatchIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: { start: number; end: number }[] = [];
|
||||||
|
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(editedContent)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length
|
||||||
|
});
|
||||||
|
// 避免无限循环
|
||||||
|
if (match.index === regex.lastIndex) regex.lastIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchMatches(matches);
|
||||||
|
setCurrentMatchIndex(matches.length > 0 ? 0 : -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索导航
|
||||||
|
const goToNextMatch = () => {
|
||||||
|
if (searchMatches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev + 1) % searchMatches.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPrevMatch = () => {
|
||||||
|
if (searchMatches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 替换功能
|
||||||
|
const handleFindReplace = (findText: string, replaceWithText: string, replaceAll: boolean = false) => {
|
||||||
|
if (!findText) return;
|
||||||
|
|
||||||
|
let newContent = editedContent;
|
||||||
|
if (replaceAll) {
|
||||||
|
newContent = newContent.replace(new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replaceWithText);
|
||||||
|
} else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) {
|
||||||
|
// 替换当前匹配项
|
||||||
|
const match = searchMatches[currentMatchIndex];
|
||||||
|
newContent = editedContent.substring(0, match.start) +
|
||||||
|
replaceWithText +
|
||||||
|
editedContent.substring(match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditedContent(newContent);
|
||||||
|
setHasChanges(newContent !== originalContent);
|
||||||
|
onContentChange?.(newContent);
|
||||||
|
|
||||||
|
// 重新搜索以更新匹配项
|
||||||
|
setTimeout(() => findMatches(findText), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFind = () => {
|
||||||
|
setShowSearchPanel(true);
|
||||||
|
setShowReplacePanel(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplace = () => {
|
||||||
|
setShowSearchPanel(true);
|
||||||
|
setShowReplacePanel(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染带高亮的文本
|
||||||
|
const renderHighlightedText = (text: string) => {
|
||||||
|
if (!searchText || searchMatches.length === 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
searchMatches.forEach((match, index) => {
|
||||||
|
// 添加匹配前的文本
|
||||||
|
if (match.start > lastIndex) {
|
||||||
|
parts.push(text.substring(lastIndex, match.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加高亮的匹配文本
|
||||||
|
const isCurrentMatch = index === currentMatchIndex;
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key={`match-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"font-bold",
|
||||||
|
isCurrentMatch
|
||||||
|
? "text-red-600 bg-yellow-200"
|
||||||
|
: "text-blue-800 bg-blue-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text.substring(match.start, match.end)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加最后的文本
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.substring(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理用户确认打开大文件
|
||||||
|
const handleConfirmOpenAsText = () => {
|
||||||
|
setForceShowAsText(true);
|
||||||
|
setShowLargeFileWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理用户拒绝打开大文件
|
||||||
|
const handleCancelOpenAsText = () => {
|
||||||
|
setShowLargeFileWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-sm text-gray-600">Loading file...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-background">
|
||||||
|
{/* 文件信息头部 */}
|
||||||
|
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
||||||
|
{fileTypeInfo.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">{file.name}</h3>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>{formatFileSize(file.size)}</span>
|
||||||
|
{file.modified && <span>Modified: {file.modified}</span>}
|
||||||
|
<span className={cn("px-2 py-1 rounded-full text-xs", fileTypeInfo.color, "bg-muted")}>
|
||||||
|
{fileTypeInfo.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 编辑工具栏 - 直接显示,无需切换 */}
|
||||||
|
{isEditable && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFind}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
Find
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReplace}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Replace className="w-4 h-4" />
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRevert}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Revert
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onDownload && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDownload}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索和替换面板 */}
|
||||||
|
{showSearchPanel && (
|
||||||
|
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Find..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
findMatches(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-48 h-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrevMatch}
|
||||||
|
disabled={searchMatches.length === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNextMatch}
|
||||||
|
disabled={searchMatches.length === 0}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground min-w-[3rem]">
|
||||||
|
{searchMatches.length > 0
|
||||||
|
? `${currentMatchIndex + 1}/${searchMatches.length}`
|
||||||
|
: searchText ? '0/0' : ''
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSearchPanel(false);
|
||||||
|
setSearchText('');
|
||||||
|
setSearchMatches([]);
|
||||||
|
setCurrentMatchIndex(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showReplacePanel && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Replace with..."
|
||||||
|
value={replaceText}
|
||||||
|
onChange={(e) => setReplaceText(e.target.value)}
|
||||||
|
className="w-48 h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleFindReplace(searchText, replaceText, false)}
|
||||||
|
disabled={!searchText}
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleFindReplace(searchText, replaceText, true)}
|
||||||
|
disabled={!searchText}
|
||||||
|
>
|
||||||
|
Replace All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文件内容 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{/* 大文件警告对话框 */}
|
||||||
|
{showLargeFileWarning && (
|
||||||
|
<div className="h-full flex items-center justify-center bg-background">
|
||||||
|
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground mb-2">Large File Warning</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
This file is {formatFileSize(file.size)} in size, which may cause performance issues when opened as text.
|
||||||
|
</p>
|
||||||
|
{isTooLarge ? (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||||||
|
<p className="text-sm text-destructive font-medium">
|
||||||
|
File is too large (> 10MB) and cannot be opened as text for security reasons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Do you want to continue opening this file as text? This may slow down your browser.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isTooLarge && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmOpenAsText}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Open as Text
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDownload}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download Instead
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelOpenAsText}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
|
{fileTypeInfo.type === 'image' && !showLargeFileWarning && (
|
||||||
|
<div className="p-6 flex items-center justify-center h-full">
|
||||||
|
<img
|
||||||
|
src={`data:image/*;base64,${content}`}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg shadow-sm"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLElement).style.display = 'none';
|
||||||
|
// Show error message instead
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文本和代码文件预览 */}
|
||||||
|
{shouldShowAsText && !showLargeFileWarning && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{fileTypeInfo.type === 'code' ? (
|
||||||
|
// 代码文件使用CodeMirror
|
||||||
|
<div className="h-full">
|
||||||
|
{searchText && searchMatches.length > 0 ? (
|
||||||
|
// 当有搜索结果时,显示只读的高亮文本(带行号)
|
||||||
|
<div className="h-full flex bg-muted">
|
||||||
|
{/* 行号列 */}
|
||||||
|
<div className="flex-shrink-0 bg-muted border-r border-border px-2 py-4 text-xs text-muted-foreground font-mono select-none">
|
||||||
|
{editedContent.split('\n').map((_, index) => (
|
||||||
|
<div key={index + 1} className="text-right leading-5 min-w-[2rem]">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 代码内容 */}
|
||||||
|
<div className="flex-1 p-4 font-mono text-sm whitespace-pre-wrap overflow-auto text-foreground">
|
||||||
|
{renderHighlightedText(editedContent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 没有搜索时显示CodeMirror编辑器
|
||||||
|
<CodeMirror
|
||||||
|
value={editedContent}
|
||||||
|
onChange={(value) => handleContentChange(value)}
|
||||||
|
extensions={getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []}
|
||||||
|
theme="dark"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: true,
|
||||||
|
dropCursor: false,
|
||||||
|
allowMultipleSelections: false,
|
||||||
|
indentOnInput: true,
|
||||||
|
bracketMatching: true,
|
||||||
|
closeBrackets: true,
|
||||||
|
autocompletion: true,
|
||||||
|
highlightSelectionMatches: false
|
||||||
|
}}
|
||||||
|
className="h-full overflow-auto"
|
||||||
|
readOnly={!isEditable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 普通文本文件
|
||||||
|
<div className="h-full">
|
||||||
|
{isEditable ? (
|
||||||
|
<div className="h-full">
|
||||||
|
{searchText && searchMatches.length > 0 ? (
|
||||||
|
// 当有搜索结果时,显示只读的高亮文本
|
||||||
|
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||||||
|
{renderHighlightedText(editedContent)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 直接显示可编辑的textarea
|
||||||
|
<textarea
|
||||||
|
value={editedContent}
|
||||||
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
|
className="w-full h-full p-4 border-none resize-none outline-none font-mono text-sm overflow-auto bg-background text-foreground"
|
||||||
|
placeholder="Start typing..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 只有非可编辑文件(媒体文件)才显示为只读
|
||||||
|
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||||||
|
{editedContent || content || 'File is empty'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频文件预览 */}
|
||||||
|
{fileTypeInfo.type === 'video' && !showLargeFileWarning && (
|
||||||
|
<div className="p-6 flex items-center justify-center h-full">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
className="max-w-full max-h-full rounded-lg shadow-sm"
|
||||||
|
src={`data:video/*;base64,${content}`}
|
||||||
|
>
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 音频文件预览 */}
|
||||||
|
{fileTypeInfo.type === 'audio' && !showLargeFileWarning && (
|
||||||
|
<div className="p-6 flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={cn("w-24 h-24 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center", fileTypeInfo.color)}>
|
||||||
|
<Music className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
className="w-full max-w-md"
|
||||||
|
src={`data:audio/*;base64,${content}`}
|
||||||
|
>
|
||||||
|
Your browser does not support audio playback.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 未知文件类型 - 只在不能显示为文本且没有警告时显示 */}
|
||||||
|
{fileTypeInfo.type === 'unknown' && !shouldShowAsText && !showLargeFileWarning && (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">Cannot preview this file type</h3>
|
||||||
|
<p className="text-sm mb-4">
|
||||||
|
This file type is not supported for preview. You can download it to view in an external application.
|
||||||
|
</p>
|
||||||
|
{onDownload && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onDownload}
|
||||||
|
className="flex items-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download File
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部状态栏 */}
|
||||||
|
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>{file.path}</span>
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-orange-600 font-medium">● Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
// readOnly参数已移除,由FileViewer内部根据文件类型决定
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileWindow({
|
||||||
|
windowId,
|
||||||
|
file,
|
||||||
|
sshSessionId,
|
||||||
|
sshHost,
|
||||||
|
initialX = 100,
|
||||||
|
initialY = 100
|
||||||
|
}: FileWindowProps) {
|
||||||
|
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, updateWindow, windows } = useWindowManager();
|
||||||
|
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isEditable, setIsEditable] = useState(false);
|
||||||
|
const [pendingContent, setPendingContent] = useState<string>('');
|
||||||
|
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const currentWindow = windows.find(w => w.id === windowId);
|
||||||
|
|
||||||
|
// 确保SSH连接有效
|
||||||
|
const ensureSSHConnection = async () => {
|
||||||
|
try {
|
||||||
|
// 首先检查SSH连接状态
|
||||||
|
const status = await getSSHStatus(sshSessionId);
|
||||||
|
console.log('SSH connection status:', status);
|
||||||
|
|
||||||
|
if (!status.connected) {
|
||||||
|
console.log('SSH not connected, attempting to reconnect...');
|
||||||
|
|
||||||
|
// 重新建立连接
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SSH reconnection successful');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('SSH connection check/reconnect failed:', error);
|
||||||
|
// 即使连接失败也尝试继续,让具体的API调用报错
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载文件内容
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFileContent = async () => {
|
||||||
|
if (file.type !== 'file') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 确保SSH连接有效
|
||||||
|
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, // 10 seconds for important message
|
||||||
|
});
|
||||||
|
} 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 {
|
||||||
|
toast.error(`Failed to load file: ${error.message || errorData?.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFileContent();
|
||||||
|
}, [file, sshSessionId, sshHost]);
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const handleSave = async (newContent: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 确保SSH连接有效
|
||||||
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||||
|
setContent(newContent);
|
||||||
|
setPendingContent(''); // 清除待保存内容
|
||||||
|
|
||||||
|
// 清除自动保存定时器
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
autoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('File saved successfully');
|
||||||
|
} 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(`Failed to save file: ${error.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理内容变更 - 设置1分钟自动保存
|
||||||
|
const handleContentChange = (newContent: string) => {
|
||||||
|
setPendingContent(newContent);
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的1分钟自动保存定时器
|
||||||
|
autoSaveTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Auto-saving file...');
|
||||||
|
await handleSave(newContent);
|
||||||
|
toast.success('File auto-saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
toast.error('Auto-save failed');
|
||||||
|
}
|
||||||
|
}, 60000); // 1分钟 = 60000毫秒
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
// 确保SSH连接有效
|
||||||
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
|
|
||||||
|
if (response?.content) {
|
||||||
|
// Convert base64 to blob and trigger download
|
||||||
|
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('File downloaded successfully');
|
||||||
|
}
|
||||||
|
} 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 handleMinimize = () => {
|
||||||
|
minimizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
maximizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
focusWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentWindow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableWindow
|
||||||
|
title={file.name}
|
||||||
|
initialX={initialX}
|
||||||
|
initialY={initialY}
|
||||||
|
initialWidth={800}
|
||||||
|
initialHeight={600}
|
||||||
|
minWidth={400}
|
||||||
|
minHeight={300}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
isMaximized={currentWindow.isMaximized}
|
||||||
|
zIndex={currentWindow.zIndex}
|
||||||
|
>
|
||||||
|
<FileViewer
|
||||||
|
file={file}
|
||||||
|
content={pendingContent || content}
|
||||||
|
savedContent={content}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isEditable={isEditable} // 移除强制只读模式,由FileViewer内部控制
|
||||||
|
onContentChange={handleContentChange}
|
||||||
|
onSave={(newContent) => handleSave(newContent)}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
/>
|
||||||
|
</DraggableWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DraggableWindow } from './DraggableWindow';
|
||||||
|
import { Terminal } from '../../Terminal/Terminal';
|
||||||
|
import { useWindowManager } from './WindowManager';
|
||||||
|
|
||||||
|
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 { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
|
||||||
|
|
||||||
|
// 获取当前窗口状态
|
||||||
|
const currentWindow = windows.find(w => w.id === windowId);
|
||||||
|
if (!currentWindow) {
|
||||||
|
console.warn(`Window with id ${windowId} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
closeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
minimizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
maximizeWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
focusWindow(windowId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminalTitle = executeCommand
|
||||||
|
? `运行 - ${hostConfig.name}:${executeCommand}`
|
||||||
|
: initialPath
|
||||||
|
? `终端 - ${hostConfig.name}:${initialPath}`
|
||||||
|
: `终端 - ${hostConfig.name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableWindow
|
||||||
|
title={terminalTitle}
|
||||||
|
initialX={initialX}
|
||||||
|
initialY={initialY}
|
||||||
|
initialWidth={800}
|
||||||
|
initialHeight={500}
|
||||||
|
minWidth={600}
|
||||||
|
minHeight={400}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
isMaximized={currentWindow.isMaximized}
|
||||||
|
zIndex={currentWindow.zIndex}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
hostConfig={hostConfig}
|
||||||
|
isVisible={!currentWindow.isMinimized}
|
||||||
|
initialPath={initialPath}
|
||||||
|
executeCommand={executeCommand}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
</DraggableWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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) * 30;
|
||||||
|
const adjustedX = windowData.x + offset;
|
||||||
|
const adjustedY = windowData.y + offset;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for using window manager
|
||||||
|
export function useWindowManager() {
|
||||||
|
const context = React.useContext(WindowManagerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useWindowManager must be used within a WindowManager');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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; // in MB
|
||||||
|
allowedTypes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDragAndDrop({
|
||||||
|
onFilesDropped,
|
||||||
|
onError,
|
||||||
|
maxFileSize = 100, // 100MB default
|
||||||
|
allowedTypes = [] // empty means all types allowed
|
||||||
|
}: 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];
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type if restrictions exist
|
||||||
|
if (allowedTypes.length > 0) {
|
||||||
|
const fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
|
||||||
|
const isAllowed = allowedTypes.some(type => {
|
||||||
|
// Check by extension
|
||||||
|
if (type.startsWith('.')) {
|
||||||
|
return fileExt === type.slice(1);
|
||||||
|
}
|
||||||
|
// Check by MIME type
|
||||||
|
if (type.includes('/')) {
|
||||||
|
return mimeType === type || mimeType.startsWith(type.replace('*', ''));
|
||||||
|
}
|
||||||
|
// Check by category
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Set dropEffect to indicate what operation is allowed
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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[]) => {
|
||||||
|
console.log('Setting selection to:', files.map(f => f.name));
|
||||||
|
setSelectedFiles(files);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedFiles,
|
||||||
|
selectFile,
|
||||||
|
selectRange,
|
||||||
|
selectAll,
|
||||||
|
clearSelection,
|
||||||
|
toggleSelection,
|
||||||
|
isSelected,
|
||||||
|
getSelectedCount,
|
||||||
|
setSelection
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -82,7 +82,11 @@ export function HostManager({
|
|||||||
{t("hosts.hostViewer")}
|
{t("hosts.hostViewer")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="add_host">
|
<TabsTrigger value="add_host">
|
||||||
{editingHost ? t("hosts.editHost") : t("hosts.addHost")}
|
{editingHost
|
||||||
|
? editingHost.id
|
||||||
|
? t("hosts.editHost")
|
||||||
|
: t("hosts.cloneHost")
|
||||||
|
: t("hosts.addHost")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
||||||
<TabsTrigger value="credentials">
|
<TabsTrigger value="credentials">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface SSHHost {
|
|||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: string;
|
authType: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
requirePassword?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
@@ -172,6 +173,7 @@ export function HostManagerEditor({
|
|||||||
authType: z.enum(["password", "key", "credential"]),
|
authType: z.enum(["password", "key", "credential"]),
|
||||||
credentialId: z.number().optional().nullable(),
|
credentialId: z.number().optional().nullable(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
|
requirePassword: z.boolean().default(true),
|
||||||
key: z.any().optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z
|
keyType: z
|
||||||
@@ -206,7 +208,7 @@ export function HostManagerEditor({
|
|||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.authType === "password") {
|
if (data.authType === "password") {
|
||||||
if (!data.password || data.password.trim() === "") {
|
if (data.requirePassword && (!data.password || data.password.trim() === "")) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t("hosts.passwordRequired"),
|
message: t("hosts.passwordRequired"),
|
||||||
@@ -274,6 +276,7 @@ export function HostManagerEditor({
|
|||||||
authType: "password" as const,
|
authType: "password" as const,
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: "",
|
password: "",
|
||||||
|
requirePassword: true,
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
@@ -330,6 +333,7 @@ export function HostManagerEditor({
|
|||||||
authType: defaultAuthType as "password" | "key" | "credential",
|
authType: defaultAuthType as "password" | "key" | "credential",
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: "",
|
password: "",
|
||||||
|
requirePassword: cleanedHost.requirePassword ?? true,
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
@@ -343,7 +347,7 @@ export function HostManagerEditor({
|
|||||||
if (defaultAuthType === "password") {
|
if (defaultAuthType === "password") {
|
||||||
formData.password = cleanedHost.password || "";
|
formData.password = cleanedHost.password || "";
|
||||||
} else if (defaultAuthType === "key") {
|
} else if (defaultAuthType === "key") {
|
||||||
formData.key = "existing_key";
|
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
||||||
formData.keyPassword = cleanedHost.keyPassword || "";
|
formData.keyPassword = cleanedHost.keyPassword || "";
|
||||||
formData.keyType = (cleanedHost.keyType as any) || "auto";
|
formData.keyType = (cleanedHost.keyType as any) || "auto";
|
||||||
} else if (defaultAuthType === "credential") {
|
} else if (defaultAuthType === "credential") {
|
||||||
@@ -365,6 +369,7 @@ export function HostManagerEditor({
|
|||||||
authType: "password" as const,
|
authType: "password" as const,
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: "",
|
password: "",
|
||||||
|
requirePassword: true,
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
@@ -420,7 +425,7 @@ export function HostManagerEditor({
|
|||||||
submitData.keyType = null;
|
submitData.keyType = null;
|
||||||
|
|
||||||
if (data.authType === "credential") {
|
if (data.authType === "credential") {
|
||||||
if (data.credentialId === "existing_credential") {
|
if (data.credentialId === "existing_credential" && editingHost && editingHost.id) {
|
||||||
delete submitData.credentialId;
|
delete submitData.credentialId;
|
||||||
} else {
|
} else {
|
||||||
submitData.credentialId = data.credentialId;
|
submitData.credentialId = data.credentialId;
|
||||||
@@ -440,7 +445,7 @@ export function HostManagerEditor({
|
|||||||
submitData.keyType = data.keyType;
|
submitData.keyType = data.keyType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingHost) {
|
if (editingHost && editingHost.id) {
|
||||||
const updatedHost = await updateSSHHost(editingHost.id, submitData);
|
const updatedHost = await updateSSHHost(editingHost.id, submitData);
|
||||||
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
||||||
|
|
||||||
@@ -867,6 +872,24 @@ export function HostManagerEditor({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requirePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4">
|
||||||
|
<FormLabel>{t("hosts.requirePassword")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.requirePasswordDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
@@ -876,6 +899,7 @@ export function HostManagerEditor({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
placeholder={t("placeholders.password")}
|
placeholder={t("placeholders.password")}
|
||||||
|
disabled={!form.watch("requirePassword")}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1497,7 +1521,7 @@ export function HostManagerEditor({
|
|||||||
<footer className="shrink-0 w-full pb-0">
|
<footer className="shrink-0 w-full pb-0">
|
||||||
<Separator className="p-0.25" />
|
<Separator className="p-0.25" />
|
||||||
<Button className="translate-y-2" type="submit" variant="outline">
|
<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>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Pencil,
|
Pencil,
|
||||||
FolderMinus,
|
FolderMinus,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
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) => {
|
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("hosts.confirmRemoveFromFolder", {
|
t("hosts.confirmRemoveFromFolder", {
|
||||||
@@ -1009,6 +1018,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<p>Export host</p>
|
<p>Export host</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ interface SSHTerminalProps {
|
|||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
splitScreen?: boolean;
|
splitScreen?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
initialPath?: string;
|
||||||
|
executeCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||||
{ hostConfig, isVisible, splitScreen = false, onClose },
|
{ hostConfig, isVisible, splitScreen = false, onClose, initialPath, executeCommand },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -252,7 +254,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "connectToHost",
|
type: "connectToHost",
|
||||||
data: { cols, rows, hostConfig },
|
data: { cols, rows, hostConfig, initialPath, executeCommand },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
@@ -412,6 +414,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
|
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
@@ -452,6 +455,49 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
};
|
};
|
||||||
element?.addEventListener("contextmenu", handleContextMenu);
|
element?.addEventListener("contextmenu", handleContextMenu);
|
||||||
|
|
||||||
|
// Add macOS-specific keyboard event handling for special characters
|
||||||
|
const handleMacKeyboard = (e: KeyboardEvent) => {
|
||||||
|
// Detect macOS
|
||||||
|
const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ||
|
||||||
|
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
if (!isMacOS) return;
|
||||||
|
|
||||||
|
// Handle Option key combinations for special characters
|
||||||
|
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||||
|
// Use both e.key and e.code to handle different keyboard layouts
|
||||||
|
const keyMappings: { [key: string]: string } = {
|
||||||
|
// Using e.key values
|
||||||
|
'7': '|', // Option+7 = pipe symbol
|
||||||
|
'2': '€', // Option+2 = euro symbol
|
||||||
|
'8': '[', // Option+8 = left bracket
|
||||||
|
'9': ']', // Option+9 = right bracket
|
||||||
|
'l': '@', // Option+L = at symbol
|
||||||
|
'L': '@', // Option+L = at symbol (uppercase)
|
||||||
|
// Using e.code values as fallback
|
||||||
|
'Digit7': '|', // Option+7 = pipe symbol
|
||||||
|
'Digit2': '€', // Option+2 = euro symbol
|
||||||
|
'Digit8': '[', // Option+8 = left bracket
|
||||||
|
'Digit9': ']', // Option+9 = right bracket
|
||||||
|
'KeyL': '@', // Option+L = at symbol
|
||||||
|
};
|
||||||
|
|
||||||
|
const char = keyMappings[e.key] || keyMappings[e.code];
|
||||||
|
if (char) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Send the character directly to the terminal
|
||||||
|
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(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
@@ -495,6 +541,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||||
|
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
if (reconnectTimeoutRef.current)
|
if (reconnectTimeoutRef.current)
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
|
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
FileDown,
|
||||||
|
FolderDown,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DragIndicatorProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
progress: number;
|
||||||
|
fileName?: string;
|
||||||
|
fileCount?: number;
|
||||||
|
error?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragIndicator({
|
||||||
|
isVisible,
|
||||||
|
isDragging,
|
||||||
|
isDownloading,
|
||||||
|
progress,
|
||||||
|
fileName,
|
||||||
|
fileCount = 1,
|
||||||
|
error,
|
||||||
|
className
|
||||||
|
}: DragIndicatorProps) {
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (error) {
|
||||||
|
return <AlertCircle className="w-6 h-6 text-red-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return <CheckCircle className="w-6 h-6 text-green-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileCount > 1) {
|
||||||
|
return <FolderDown className="w-6 h-6 text-blue-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FileDown className="w-6 h-6 text-blue-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (error) {
|
||||||
|
return `错误: ${error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return `正在拖拽${fileName ? ` ${fileName}` : ''}到桌面...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
return `正在准备拖拽${fileName ? ` ${fileName}` : ''}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `准备拖拽${fileCount > 1 ? ` ${fileCount} 个文件` : fileName ? ` ${fileName}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-4 right-4 z-50 min-w-[300px] max-w-[400px]",
|
||||||
|
"bg-dark-bg border border-dark-border rounded-lg shadow-lg",
|
||||||
|
"p-4 transition-all duration-300 ease-in-out",
|
||||||
|
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* 图标 */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="text-sm font-medium text-foreground mb-2">
|
||||||
|
{fileCount > 1 ? '批量拖拽到桌面' : '拖拽到桌面'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态文字 */}
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs mb-3",
|
||||||
|
error ? "text-red-500" :
|
||||||
|
isDragging ? "text-green-500" :
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{getStatusText()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{(isDownloading || isDragging) && !error && (
|
||||||
|
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 rounded-full transition-all duration-300",
|
||||||
|
isDragging ? "bg-green-500" : "bg-blue-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.max(5, progress)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进度百分比 */}
|
||||||
|
{(isDownloading || isDragging) && !error && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{progress.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 拖拽提示 */}
|
||||||
|
{isDragging && !error && (
|
||||||
|
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
现在可以拖拽到桌面任意位置
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动画效果的背景 */}
|
||||||
|
{isDragging && !error && (
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { downloadSSHFile } from '@/ui/main-axios';
|
||||||
|
import type { FileItem, SSHHost } from '../../types/index.js';
|
||||||
|
|
||||||
|
interface DragToDesktopState {
|
||||||
|
isDragging: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDragToDesktopProps {
|
||||||
|
sshSessionId: string;
|
||||||
|
sshHost: SSHHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragToDesktopOptions {
|
||||||
|
enableToast?: boolean;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProps) {
|
||||||
|
const [state, setState] = useState<DragToDesktopState>({
|
||||||
|
isDragging: false,
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 0,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否在Electron环境中
|
||||||
|
const isElectron = () => {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
window.electronAPI &&
|
||||||
|
window.electronAPI.isElectron;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽单个文件到桌面
|
||||||
|
const dragFileToDesktop = useCallback(async (
|
||||||
|
file: FileItem,
|
||||||
|
options: DragToDesktopOptions = {}
|
||||||
|
) => {
|
||||||
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
if (!isElectron()) {
|
||||||
|
const error = '拖拽到桌面功能仅在桌面应用中可用';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type !== 'file') {
|
||||||
|
const error = '只能拖拽文件到桌面';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null }));
|
||||||
|
|
||||||
|
// 下载文件内容
|
||||||
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
|
|
||||||
|
if (!response?.content) {
|
||||||
|
throw new Error('无法获取文件内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 50 }));
|
||||||
|
|
||||||
|
// 创建临时文件
|
||||||
|
const tempResult = await window.electronAPI.createTempFile({
|
||||||
|
fileName: file.name,
|
||||||
|
content: response.content,
|
||||||
|
encoding: 'base64'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tempResult.success) {
|
||||||
|
throw new Error(tempResult.error || '创建临时文件失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 80, isDragging: true }));
|
||||||
|
|
||||||
|
// 开始拖拽
|
||||||
|
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||||
|
tempId: tempResult.tempId,
|
||||||
|
fileName: file.name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dragResult.success) {
|
||||||
|
throw new Error(dragResult.error || '开始拖拽失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 100 }));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.success(`正在拖拽 ${file.name} 到桌面`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
// 延迟清理临时文件(给用户时间完成拖拽)
|
||||||
|
setTimeout(async () => {
|
||||||
|
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDragging: false,
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 0
|
||||||
|
}));
|
||||||
|
}, 10000); // 10秒后清理
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('拖拽到桌面失败:', error);
|
||||||
|
const errorMessage = error.message || '拖拽失败';
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
isDragging: false,
|
||||||
|
progress: 0,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.error(`拖拽失败: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [sshSessionId, sshHost]);
|
||||||
|
|
||||||
|
// 拖拽多个文件到桌面(批量操作)
|
||||||
|
const dragFilesToDesktop = useCallback(async (
|
||||||
|
files: FileItem[],
|
||||||
|
options: DragToDesktopOptions = {}
|
||||||
|
) => {
|
||||||
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
if (!isElectron()) {
|
||||||
|
const error = '拖拽到桌面功能仅在桌面应用中可用';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileList = files.filter(f => f.type === 'file');
|
||||||
|
if (fileList.length === 0) {
|
||||||
|
const error = '没有可拖拽的文件';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileList.length === 1) {
|
||||||
|
return dragFileToDesktop(fileList[0], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null }));
|
||||||
|
|
||||||
|
// 批量下载文件
|
||||||
|
const downloadPromises = fileList.map(file =>
|
||||||
|
downloadSSHFile(sshSessionId, file.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(downloadPromises);
|
||||||
|
setState(prev => ({ ...prev, progress: 40 }));
|
||||||
|
|
||||||
|
// 创建临时文件夹结构
|
||||||
|
const folderName = `Files_${Date.now()}`;
|
||||||
|
const filesData = fileList.map((file, index) => ({
|
||||||
|
relativePath: file.name,
|
||||||
|
content: responses[index]?.content || '',
|
||||||
|
encoding: 'base64'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tempResult = await window.electronAPI.createTempFolder({
|
||||||
|
folderName,
|
||||||
|
files: filesData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tempResult.success) {
|
||||||
|
throw new Error(tempResult.error || '创建临时文件夹失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 80, isDragging: true }));
|
||||||
|
|
||||||
|
// 开始拖拽文件夹
|
||||||
|
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||||
|
tempId: tempResult.tempId,
|
||||||
|
fileName: folderName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dragResult.success) {
|
||||||
|
throw new Error(dragResult.error || '开始拖拽失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 100 }));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.success(`正在拖拽 ${fileList.length} 个文件到桌面`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
// 延迟清理临时文件夹
|
||||||
|
setTimeout(async () => {
|
||||||
|
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDragging: false,
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 0
|
||||||
|
}));
|
||||||
|
}, 15000); // 15秒后清理
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('批量拖拽到桌面失败:', error);
|
||||||
|
const errorMessage = error.message || '批量拖拽失败';
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
isDragging: false,
|
||||||
|
progress: 0,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.error(`批量拖拽失败: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [sshSessionId, sshHost, dragFileToDesktop]);
|
||||||
|
|
||||||
|
// 拖拽文件夹到桌面
|
||||||
|
const dragFolderToDesktop = useCallback(async (
|
||||||
|
folder: FileItem,
|
||||||
|
options: DragToDesktopOptions = {}
|
||||||
|
) => {
|
||||||
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
if (!isElectron()) {
|
||||||
|
const error = '拖拽到桌面功能仅在桌面应用中可用';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folder.type !== 'directory') {
|
||||||
|
const error = '只能拖拽文件夹类型';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.info('文件夹拖拽功能开发中...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 实现文件夹递归下载和拖拽
|
||||||
|
// 这需要额外的API来递归获取文件夹内容
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [sshSessionId, sshHost]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isElectron: isElectron(),
|
||||||
|
dragFileToDesktop,
|
||||||
|
dragFilesToDesktop,
|
||||||
|
dragFolderToDesktop
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { downloadSSHFile } from '@/ui/main-axios';
|
||||||
|
import type { FileItem, SSHHost } from '../../types/index.js';
|
||||||
|
|
||||||
|
interface DragToSystemState {
|
||||||
|
isDragging: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDragToSystemProps {
|
||||||
|
sshSessionId: string;
|
||||||
|
sshHost: SSHHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragToSystemOptions {
|
||||||
|
enableToast?: boolean;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSystemProps) {
|
||||||
|
const [state, setState] = useState<DragToSystemState>({
|
||||||
|
isDragging: false,
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 0,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragDataRef = useRef<{ files: FileItem[], options: DragToSystemOptions } | null>(null);
|
||||||
|
|
||||||
|
// 目录记忆功能
|
||||||
|
const getLastSaveDirectory = async () => {
|
||||||
|
try {
|
||||||
|
if ('indexedDB' in window) {
|
||||||
|
const request = indexedDB.open('termix-dirs', 1);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const transaction = db.transaction(['directories'], 'readonly');
|
||||||
|
const store = transaction.objectStore('directories');
|
||||||
|
const getRequest = store.get('lastSaveDir');
|
||||||
|
getRequest.onsuccess = () => resolve(getRequest.result?.handle || null);
|
||||||
|
};
|
||||||
|
request.onerror = () => resolve(null);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains('directories')) {
|
||||||
|
db.createObjectStore('directories');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('无法获取上次保存目录:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLastDirectory = async (fileHandle: any) => {
|
||||||
|
try {
|
||||||
|
if ('indexedDB' in window && fileHandle.getParent) {
|
||||||
|
const dirHandle = await fileHandle.getParent();
|
||||||
|
const request = indexedDB.open('termix-dirs', 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const transaction = db.transaction(['directories'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('directories');
|
||||||
|
store.put({ handle: dirHandle }, 'lastSaveDir');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('无法保存目录记录:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查File System Access API支持
|
||||||
|
const isFileSystemAPISupported = () => {
|
||||||
|
return 'showSaveFilePicker' in window;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查拖拽是否离开窗口边界
|
||||||
|
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||||
|
const margin = 50; // 增加容差边距
|
||||||
|
return (
|
||||||
|
e.clientX < margin ||
|
||||||
|
e.clientX > window.innerWidth - margin ||
|
||||||
|
e.clientY < margin ||
|
||||||
|
e.clientY > window.innerHeight - margin
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建文件blob
|
||||||
|
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||||
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
|
if (!response?.content) {
|
||||||
|
throw new Error(`无法获取文件 ${file.name} 的内容`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64转换为blob
|
||||||
|
const binaryString = atob(response.content);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建ZIP文件(用于多文件下载)
|
||||||
|
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||||
|
// 这里需要一个轻量级的zip库,先用简单方案
|
||||||
|
const JSZip = (await import('jszip')).default;
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const blob = await createFileBlob(file);
|
||||||
|
zip.file(file.name, blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await zip.generateAsync({ type: 'blob' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用File System Access API保存文件
|
||||||
|
const saveFileWithSystemAPI = async (blob: Blob, suggestedName: string) => {
|
||||||
|
try {
|
||||||
|
// 获取上次保存的目录句柄
|
||||||
|
const lastDirHandle = await getLastSaveDirectory();
|
||||||
|
|
||||||
|
const fileHandle = await (window as any).showSaveFilePicker({
|
||||||
|
suggestedName,
|
||||||
|
startIn: lastDirHandle || 'desktop', // 优先使用上次目录,否则桌面
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
description: '文件',
|
||||||
|
accept: {
|
||||||
|
'*/*': ['.txt', '.jpg', '.png', '.pdf', '.zip', '.tar', '.gz']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存当前目录句柄以便下次使用
|
||||||
|
await saveLastDirectory(fileHandle);
|
||||||
|
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
await writable.write(blob);
|
||||||
|
await writable.close();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return false; // 用户取消
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 降级方案:传统下载
|
||||||
|
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理拖拽到系统桌面
|
||||||
|
const handleDragToSystem = useCallback(async (
|
||||||
|
files: FileItem[],
|
||||||
|
options: DragToSystemOptions = {}
|
||||||
|
) => {
|
||||||
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
const error = '没有可拖拽的文件';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤出文件类型
|
||||||
|
const fileList = files.filter(f => f.type === 'file');
|
||||||
|
if (fileList.length === 0) {
|
||||||
|
const error = '只能拖拽文件到桌面';
|
||||||
|
if (enableToast) toast.error(error);
|
||||||
|
onError?.(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null }));
|
||||||
|
|
||||||
|
let blob: Blob;
|
||||||
|
let fileName: string;
|
||||||
|
|
||||||
|
if (fileList.length === 1) {
|
||||||
|
// 单文件
|
||||||
|
blob = await createFileBlob(fileList[0]);
|
||||||
|
fileName = fileList[0].name;
|
||||||
|
setState(prev => ({ ...prev, progress: 70 }));
|
||||||
|
} else {
|
||||||
|
// 多文件打包成ZIP
|
||||||
|
blob = await createZipBlob(fileList);
|
||||||
|
fileName = `files_${Date.now()}.zip`;
|
||||||
|
setState(prev => ({ ...prev, progress: 70 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 90 }));
|
||||||
|
|
||||||
|
// 优先使用File System Access API
|
||||||
|
if (isFileSystemAPISupported()) {
|
||||||
|
const saved = await saveFileWithSystemAPI(blob, fileName);
|
||||||
|
if (!saved) {
|
||||||
|
// 用户取消了
|
||||||
|
setState(prev => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 降级到传统下载
|
||||||
|
fallbackDownload(blob, fileName);
|
||||||
|
if (enableToast) {
|
||||||
|
toast.info('由于浏览器限制,文件将下载到默认下载目录');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, progress: 100 }));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.success(
|
||||||
|
fileList.length === 1
|
||||||
|
? `${fileName} 已保存到指定位置`
|
||||||
|
: `${fileList.length} 个文件已打包保存`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
setTimeout(() => {
|
||||||
|
setState(prev => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('拖拽到桌面失败:', error);
|
||||||
|
const errorMessage = error.message || '保存失败';
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 0,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (enableToast) {
|
||||||
|
toast.error(`保存失败: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [sshSessionId]);
|
||||||
|
|
||||||
|
// 开始拖拽(记录拖拽数据)
|
||||||
|
const startDragToSystem = useCallback((files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||||
|
dragDataRef.current = { files, options };
|
||||||
|
setState(prev => ({ ...prev, isDragging: true, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 结束拖拽检测
|
||||||
|
const handleDragEnd = useCallback((e: DragEvent) => {
|
||||||
|
if (!dragDataRef.current) return;
|
||||||
|
|
||||||
|
const { files, options } = dragDataRef.current;
|
||||||
|
|
||||||
|
// 检查是否拖拽到窗口外
|
||||||
|
if (isDraggedOutsideWindow(e)) {
|
||||||
|
// 延迟执行,避免与其他拖拽事件冲突
|
||||||
|
setTimeout(() => {
|
||||||
|
handleDragToSystem(files, options);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理拖拽状态
|
||||||
|
dragDataRef.current = null;
|
||||||
|
setState(prev => ({ ...prev, isDragging: false }));
|
||||||
|
}, [handleDragToSystem]);
|
||||||
|
|
||||||
|
// 取消拖拽
|
||||||
|
const cancelDragToSystem = useCallback(() => {
|
||||||
|
dragDataRef.current = null;
|
||||||
|
setState(prev => ({ ...prev, isDragging: false, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isFileSystemAPISupported: isFileSystemAPISupported(),
|
||||||
|
startDragToSystem,
|
||||||
|
handleDragEnd,
|
||||||
|
cancelDragToSystem,
|
||||||
|
handleDragToSystem // 直接调用版本
|
||||||
|
};
|
||||||
|
}
|
||||||
+326
-11
@@ -958,14 +958,29 @@ export async function getSSHStatus(
|
|||||||
export async function listSSHFiles(
|
export async function listSSHFiles(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<any[]> {
|
): Promise<{files: any[], path: string}> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||||
params: { sessionId, path },
|
params: { sessionId, path },
|
||||||
});
|
});
|
||||||
return response.data || [];
|
return response.data || {files: [], path};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "list SSH files");
|
handleApiError(error, "list SSH files");
|
||||||
|
return {files: [], path}; // 确保总是返回正确格式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function identifySSHSymlink(
|
||||||
|
sessionId: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||||
|
params: { sessionId, path },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "identify SSH symlink");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,6 +1051,25 @@ export async function uploadSSHFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadSSHFile(
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post("/ssh/downloadFile", {
|
||||||
|
sessionId,
|
||||||
|
path: filePath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "download SSH file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSSHFile(
|
export async function createSSHFile(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -1103,6 +1137,30 @@ export async function deleteSSHItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copySSHItem(
|
||||||
|
sessionId: string,
|
||||||
|
sourcePath: string,
|
||||||
|
targetDir: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post("/ssh/copyItem", {
|
||||||
|
sessionId,
|
||||||
|
sourcePath,
|
||||||
|
targetDir,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
}, {
|
||||||
|
timeout: 60000, // 60秒超时,因为文件复制可能需要更长时间
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "copy SSH item");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameSSHItem(
|
export async function renameSSHItem(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
oldPath: string,
|
oldPath: string,
|
||||||
@@ -1121,6 +1179,171 @@ export async function renameSSHItem(
|
|||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "rename SSH item");
|
handleApiError(error, "rename SSH item");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveSSHItem(
|
||||||
|
sessionId: string,
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.put("/ssh/moveItem", {
|
||||||
|
sessionId,
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "move SSH item");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILE MANAGER DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Recent Files
|
||||||
|
export async function getRecentFiles(hostId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/ssh/file_manager/recent", {
|
||||||
|
params: { hostId }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "get recent files");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRecentFile(
|
||||||
|
hostId: number,
|
||||||
|
path: string,
|
||||||
|
name?: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/ssh/file_manager/recent", {
|
||||||
|
hostId,
|
||||||
|
path,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "add recent file");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRecentFile(
|
||||||
|
hostId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete("/ssh/file_manager/recent", {
|
||||||
|
data: { hostId, path }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "remove recent file");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned Files
|
||||||
|
export async function getPinnedFiles(hostId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/ssh/file_manager/pinned", {
|
||||||
|
params: { hostId }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "get pinned files");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPinnedFile(
|
||||||
|
hostId: number,
|
||||||
|
path: string,
|
||||||
|
name?: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/ssh/file_manager/pinned", {
|
||||||
|
hostId,
|
||||||
|
path,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "add pinned file");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePinnedFile(
|
||||||
|
hostId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete("/ssh/file_manager/pinned", {
|
||||||
|
data: { hostId, path }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "remove pinned file");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder Shortcuts
|
||||||
|
export async function getFolderShortcuts(hostId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
||||||
|
params: { hostId }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "get folder shortcuts");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFolderShortcut(
|
||||||
|
hostId: number,
|
||||||
|
path: string,
|
||||||
|
name?: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/ssh/file_manager/shortcuts", {
|
||||||
|
hostId,
|
||||||
|
path,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "add folder shortcut");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFolderShortcut(
|
||||||
|
hostId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete("/ssh/file_manager/shortcuts", {
|
||||||
|
data: { hostId, path }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "remove folder shortcut");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1506,7 +1729,7 @@ export async function getCredentials(): Promise<any> {
|
|||||||
const response = await authApi.get("/credentials");
|
const response = await authApi.get("/credentials");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch credentials");
|
throw handleApiError(error, "fetch credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,7 +1738,7 @@ export async function getCredentialDetails(credentialId: number): Promise<any> {
|
|||||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch credential details");
|
throw handleApiError(error, "fetch credential details");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,7 +1747,7 @@ export async function createCredential(credentialData: any): Promise<any> {
|
|||||||
const response = await authApi.post("/credentials", credentialData);
|
const response = await authApi.post("/credentials", credentialData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "create credential");
|
throw handleApiError(error, "create credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,7 +1762,7 @@ export async function updateCredential(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "update credential");
|
throw handleApiError(error, "update credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,7 +1771,7 @@ export async function deleteCredential(credentialId: number): Promise<any> {
|
|||||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "delete credential");
|
throw handleApiError(error, "delete credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1594,7 +1817,7 @@ export async function applyCredentialToHost(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "apply credential to host");
|
throw handleApiError(error, "apply credential to host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1604,7 +1827,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
|||||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "remove credential from host");
|
throw handleApiError(error, "remove credential from host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1620,7 +1843,7 @@ export async function migrateHostToCredential(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "migrate host to credential");
|
throw handleApiError(error, "migrate host to credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,6 +1886,98 @@ export async function renameCredentialFolder(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "rename credential folder");
|
throw handleApiError(error, "rename credential folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectKeyType(
|
||||||
|
privateKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-key-type", {
|
||||||
|
privateKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPublicKeyType(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||||
|
publicKey,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect public key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateKeyPair(
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "validate key pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePublicKeyFromPrivate(
|
||||||
|
privateKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/generate-public-key", {
|
||||||
|
privateKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "generate public key from private key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair(
|
||||||
|
keyType: 'ssh-ed25519' | 'ssh-rsa' | 'ecdsa-sha2-nistp256',
|
||||||
|
keySize?: number,
|
||||||
|
passphrase?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||||
|
keyType,
|
||||||
|
keySize,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "generate SSH key pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deployCredentialToHost(
|
||||||
|
credentialId: number,
|
||||||
|
targetHostId: number,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(
|
||||||
|
`/credentials/${credentialId}/deploy-to-host`,
|
||||||
|
{ targetHostId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "deploy credential to host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<TabsContent value="key">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Private Key Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel className="text-sm font-medium">
|
||||||
|
{t("credentials.sshPrivateKey")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.uploadFile")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<input
|
||||||
|
id="key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pem,.key,.txt,.ppk"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
field.onChange(file);
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
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="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{field.value instanceof File
|
||||||
|
? field.value.name
|
||||||
|
: t("credentials.upload")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.pasteKey")}
|
||||||
|
</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"
|
||||||
|
value={typeof field.value === "string" ? field.value : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key type detection display */}
|
||||||
|
{detectedKeyType && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<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.detecting")}...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show existing private key for editing */}
|
||||||
|
{editingCredential && fullCredentialDetails?.key && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted 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"
|
||||||
|
value={fullCredentialDetails.key}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.currentKeyContent")}
|
||||||
|
</div>
|
||||||
|
{fullCredentialDetails?.detectedKeyType && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Key type: </span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public Key Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel className="text-sm font-medium">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.uploadFile")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<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">
|
||||||
|
{field.value ? t("credentials.publicKeyUploaded") : t("credentials.uploadPublicKey")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.pasteKey")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
placeholder={t("placeholders.pastePublicKey")}
|
||||||
|
className="flex min-h-[80px] 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"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedPublicKeyDetection(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public key type detection */}
|
||||||
|
{detectedPublicKeyType && form.watch("publicKey") && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<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.detecting")}...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show existing public key for editing */}
|
||||||
|
{editingCredential && fullCredentialDetails?.publicKey && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted 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"
|
||||||
|
value={fullCredentialDetails.publicKey}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.currentPublicKeyContent")}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Public Key Button */}
|
||||||
|
{form.watch("key") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGeneratePublicKey}
|
||||||
|
disabled={generatePublicKeyLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{generatePublicKeyLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="mr-2">{t("credentials.generating")}...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t("credentials.generatePublicKey")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||||
|
{t("credentials.generatePublicKeyNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
Reference in New Issue
Block a user
The recommendation for the encryption key length could be clearer. For AES-256, a 32-byte (256-bit) key is required. Your example
openssl rand -hex 32correctly generates this (a 64-character hex string). However, the text "At least 16 characters long" is misleading as it suggests a 128-bit key would suffice, which is weaker than the AES-256 you've implemented. It would be better to consistently recommend a 32-byte key, for example, by specifying a 64-character hex string.