Cleanup files and improve file manager.

This commit is contained in:
LukeGus
2025-09-18 00:32:56 -05:00
parent cb7bb3c864
commit 8afd84d96d
53 changed files with 6354 additions and 4736 deletions

View File

@@ -5,9 +5,9 @@ on:
branches: branches:
- development - development
paths-ignore: paths-ignore:
- '**.md' - "**.md"
- '.gitignore' - ".gitignore"
- 'docker/**' - "docker/**"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
build_type: build_type:
@@ -34,8 +34,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -77,8 +77,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci

View File

@@ -61,7 +61,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Background Colors ### Background Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|-------------------------------|-------------|-----------------------------|------------------------------------------| | ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color | | `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
@@ -73,7 +73,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Element-Specific Backgrounds ### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|--------------------|-----------------------------------------------| | ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements | | `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | | `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
@@ -82,7 +82,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Border Colors ### Border Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|------------------------------|-------------|-----------------|------------------------------------------| | ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border` | `#303032` | Default borders | Standard border color | | `--color-dark-border` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
@@ -93,7 +93,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Interactive States ### Interactive States
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|-------------------|-----------------------------------------------| | ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects | | `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements | | `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements | | `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |

View File

@@ -9,17 +9,20 @@ Termix implements AES-256-GCM encryption for sensitive data stored in the databa
The following database fields are automatically encrypted: The following database fields are automatically encrypted:
**Users Table:** **Users Table:**
- `password_hash` - User password hashes - `password_hash` - User password hashes
- `client_secret` - OIDC client secrets - `client_secret` - OIDC client secrets
- `totp_secret` - 2FA authentication seeds - `totp_secret` - 2FA authentication seeds
- `totp_backup_codes` - 2FA backup codes - `totp_backup_codes` - 2FA backup codes
**SSH Data Table:** **SSH Data Table:**
- `password` - SSH connection passwords - `password` - SSH connection passwords
- `key` - SSH private keys - `key` - SSH private keys
- `keyPassword` - SSH private key passphrases - `keyPassword` - SSH private key passphrases
**SSH Credentials Table:** **SSH Credentials Table:**
- `password` - Stored SSH passwords - `password` - Stored SSH passwords
- `privateKey` - SSH private keys - `privateKey` - SSH private keys
- `keyPassword` - SSH private key passphrases - `keyPassword` - SSH private key passphrases
@@ -34,6 +37,7 @@ DB_ENCRYPTION_KEY=your-very-strong-encryption-key-32-chars-minimum
``` ```
**⚠️ CRITICAL:** The encryption key must be: **⚠️ CRITICAL:** The encryption key must be:
- At least 16 characters long (32+ recommended) - At least 16 characters long (32+ recommended)
- Cryptographically random - Cryptographically random
- Unique per installation - Unique per installation
@@ -190,16 +194,19 @@ Monitor logs for encryption-related events:
#### Common Issues #### Common Issues
**1. "Decryption failed" errors** **1. "Decryption failed" errors**
- Verify `DB_ENCRYPTION_KEY` is correct - Verify `DB_ENCRYPTION_KEY` is correct
- Check if database was corrupted - Check if database was corrupted
- Restore from backup if necessary - Restore from backup if necessary
**2. Performance issues** **2. Performance issues**
- Encryption adds ~1ms per operation - Encryption adds ~1ms per operation
- Consider disabling `MIGRATE_ON_ACCESS` after migration - Consider disabling `MIGRATE_ON_ACCESS` after migration
- Monitor CPU usage during large migrations - Monitor CPU usage during large migrations
**3. Key rotation** **3. Key rotation**
```bash ```bash
# Generate new key # Generate new key
NEW_KEY=$(openssl rand -hex 32) NEW_KEY=$(openssl rand -hex 32)
@@ -220,11 +227,13 @@ This encryption implementation helps meet requirements for:
### Security Limitations ### Security Limitations
**What this protects against:** **What this protects against:**
- Database file theft - Database file theft
- Disk access by unauthorized users - Disk access by unauthorized users
- Data breaches from file system access - Data breaches from file system access
**What this does NOT protect against:** **What this does NOT protect against:**
- Application-level vulnerabilities - Application-level vulnerabilities
- Memory dumps while application is running - Memory dumps while application is running
- Attacks against the running application - Attacks against the running application
@@ -251,6 +260,7 @@ This encryption implementation helps meet requirements for:
### Support ### Support
For security-related questions: For security-related questions:
- Open issue: [GitHub Issues](https://github.com/LukeGus/Termix/issues) - Open issue: [GitHub Issues](https://github.com/LukeGus/Termix/issues)
- Discord: [Termix Community](https://discord.gg/jVQGdvHDrf) - Discord: [Termix Community](https://discord.gg/jVQGdvHDrf)

View File

@@ -326,31 +326,31 @@ const tempFiles = new Map(); // 存储临时文件路径映射
// 创建临时文件 // 创建临时文件
ipcMain.handle("create-temp-file", async (event, fileData) => { ipcMain.handle("create-temp-file", async (event, fileData) => {
try { try {
const { fileName, content, encoding = 'base64' } = fileData; const { fileName, content, encoding = "base64" } = fileData;
// 创建临时目录 // 创建临时目录
const tempDir = path.join(os.tmpdir(), 'termix-drag-files'); const tempDir = path.join(os.tmpdir(), "termix-drag-files");
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
} }
// 生成临时文件路径 // 生成临时文件路径
const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); const tempId = Date.now() + "-" + Math.random().toString(36).substr(2, 9);
const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`); const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`);
// 写入文件内容 // 写入文件内容
if (encoding === 'base64') { if (encoding === "base64") {
const buffer = Buffer.from(content, 'base64'); const buffer = Buffer.from(content, "base64");
fs.writeFileSync(tempFilePath, buffer); fs.writeFileSync(tempFilePath, buffer);
} else { } else {
fs.writeFileSync(tempFilePath, content, 'utf8'); fs.writeFileSync(tempFilePath, content, "utf8");
} }
// 记录临时文件 // 记录临时文件
tempFiles.set(tempId, { tempFiles.set(tempId, {
path: tempFilePath, path: tempFilePath,
fileName: fileName, fileName: fileName,
createdAt: Date.now() createdAt: Date.now(),
}); });
console.log(`Created temp file: ${tempFilePath}`); console.log(`Created temp file: ${tempFilePath}`);
@@ -375,7 +375,7 @@ ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => {
mainWindow.webContents.startDrag({ mainWindow.webContents.startDrag({
file: tempFile.path, file: tempFile.path,
icon: iconExists ? iconPath : undefined icon: iconExists ? iconPath : undefined,
}); });
console.log(`Started drag for: ${tempFile.path}`); console.log(`Started drag for: ${tempFile.path}`);
@@ -431,12 +431,12 @@ ipcMain.handle("create-temp-folder", async (event, folderData) => {
const { folderName, files } = folderData; const { folderName, files } = folderData;
// 创建临时目录 // 创建临时目录
const tempDir = path.join(os.tmpdir(), 'termix-drag-folders'); const tempDir = path.join(os.tmpdir(), "termix-drag-folders");
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
} }
const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); const tempId = Date.now() + "-" + Math.random().toString(36).substr(2, 9);
const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`); const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`);
// 递归创建文件夹结构 // 递归创建文件夹结构
@@ -451,11 +451,11 @@ ipcMain.handle("create-temp-folder", async (event, folderData) => {
} }
// 写入文件 // 写入文件
if (file.encoding === 'base64') { if (file.encoding === "base64") {
const buffer = Buffer.from(file.content, 'base64'); const buffer = Buffer.from(file.content, "base64");
fs.writeFileSync(fullPath, buffer); fs.writeFileSync(fullPath, buffer);
} else { } else {
fs.writeFileSync(fullPath, file.content, 'utf8'); fs.writeFileSync(fullPath, file.content, "utf8");
} }
} }
}; };
@@ -468,7 +468,7 @@ ipcMain.handle("create-temp-folder", async (event, folderData) => {
path: tempFolderPath, path: tempFolderPath,
fileName: folderName, fileName: folderName,
createdAt: Date.now(), createdAt: Date.now(),
isFolder: true isFolder: true,
}); });
console.log(`Created temp folder: ${tempFolderPath}`); console.log(`Created temp folder: ${tempFolderPath}`);

View File

@@ -26,13 +26,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
// ================== 拖拽API ================== // ================== 拖拽API ==================
// 创建临时文件用于拖拽 // 创建临时文件用于拖拽
createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData), createTempFile: (fileData) =>
ipcRenderer.invoke("create-temp-file", fileData),
// 创建临时文件夹用于拖拽 // 创建临时文件夹用于拖拽
createTempFolder: (folderData) => ipcRenderer.invoke("create-temp-folder", folderData), createTempFolder: (folderData) =>
ipcRenderer.invoke("create-temp-folder", folderData),
// 开始拖拽到桌面 // 开始拖拽到桌面
startDragToDesktop: (dragData) => ipcRenderer.invoke("start-drag-to-desktop", dragData), startDragToDesktop: (dragData) =>
ipcRenderer.invoke("start-drag-to-desktop", dragData),
// 清理临时文件 // 清理临时文件
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),

View File

@@ -34,13 +34,13 @@ app.use(
// Configure multer for file uploads // Configure multer for file uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, 'uploads/'); cb(null, "uploads/");
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
// Preserve original filename with timestamp prefix to avoid conflicts // Preserve original filename with timestamp prefix to avoid conflicts
const timestamp = Date.now(); const timestamp = Date.now();
cb(null, `${timestamp}-${file.originalname}`); cb(null, `${timestamp}-${file.originalname}`);
} },
}); });
const upload = multer({ const upload = multer({
@@ -50,12 +50,15 @@ const upload = multer({
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Allow SQLite files // Allow SQLite files
if (file.originalname.endsWith('.termix-export.sqlite') || file.originalname.endsWith('.sqlite')) { if (
file.originalname.endsWith(".termix-export.sqlite") ||
file.originalname.endsWith(".sqlite")
) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only .termix-export.sqlite files are allowed')); cb(new Error("Only .termix-export.sqlite files are allowed"));
}
} }
},
}); });
interface CacheEntry { interface CacheEntry {
@@ -295,11 +298,11 @@ app.get("/encryption/status", async (req, res) => {
res.json({ res.json({
encryption: detailedStatus, encryption: detailedStatus,
migration: migrationStatus migration: migrationStatus,
}); });
} catch (error) { } catch (error) {
apiLogger.error("Failed to get encryption status", error, { apiLogger.error("Failed to get encryption status", error, {
operation: "encryption_status" operation: "encryption_status",
}); });
res.status(500).json({ error: "Failed to get encryption status" }); res.status(500).json({ error: "Failed to get encryption status" });
} }
@@ -307,24 +310,26 @@ app.get("/encryption/status", async (req, res) => {
app.post("/encryption/initialize", async (req, res) => { app.post("/encryption/initialize", async (req, res) => {
try { try {
const { EncryptionKeyManager } = await import("../utils/encryption-key-manager.js"); const { EncryptionKeyManager } = await import(
"../utils/encryption-key-manager.js"
);
const keyManager = EncryptionKeyManager.getInstance(); const keyManager = EncryptionKeyManager.getInstance();
const newKey = await keyManager.generateNewKey(); const newKey = await keyManager.generateNewKey();
await DatabaseEncryption.initialize({ masterPassword: newKey }); await DatabaseEncryption.initialize({ masterPassword: newKey });
apiLogger.info("Encryption initialized via API", { apiLogger.info("Encryption initialized via API", {
operation: "encryption_init_api" operation: "encryption_init_api",
}); });
res.json({ res.json({
success: true, success: true,
message: "Encryption initialized successfully", message: "Encryption initialized successfully",
keyPreview: newKey.substring(0, 8) + "..." keyPreview: newKey.substring(0, 8) + "...",
}); });
} catch (error) { } catch (error) {
apiLogger.error("Failed to initialize encryption", error, { apiLogger.error("Failed to initialize encryption", error, {
operation: "encryption_init_api_failed" operation: "encryption_init_api_failed",
}); });
res.status(500).json({ error: "Failed to initialize encryption" }); res.status(500).json({ error: "Failed to initialize encryption" });
} }
@@ -336,38 +341,38 @@ app.post("/encryption/migrate", async (req, res) => {
const migration = new EncryptionMigration({ const migration = new EncryptionMigration({
dryRun, dryRun,
backupEnabled: true backupEnabled: true,
}); });
if (dryRun) { if (dryRun) {
apiLogger.info("Starting encryption migration (dry run)", { apiLogger.info("Starting encryption migration (dry run)", {
operation: "encryption_migrate_dry_run" operation: "encryption_migrate_dry_run",
}); });
res.json({ res.json({
success: true, success: true,
message: "Dry run mode - no changes made", message: "Dry run mode - no changes made",
dryRun: true dryRun: true,
}); });
} else { } else {
apiLogger.info("Starting encryption migration", { apiLogger.info("Starting encryption migration", {
operation: "encryption_migrate" operation: "encryption_migrate",
}); });
await migration.runMigration(); await migration.runMigration();
res.json({ res.json({
success: true, success: true,
message: "Migration completed successfully" message: "Migration completed successfully",
}); });
} }
} catch (error) { } catch (error) {
apiLogger.error("Migration failed", error, { apiLogger.error("Migration failed", error, {
operation: "encryption_migrate_failed" operation: "encryption_migrate_failed",
}); });
res.status(500).json({ res.status(500).json({
error: "Migration failed", error: "Migration failed",
details: error instanceof Error ? error.message : "Unknown error" details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
@@ -377,17 +382,17 @@ app.post("/encryption/regenerate", async (req, res) => {
await DatabaseEncryption.reinitializeWithNewKey(); await DatabaseEncryption.reinitializeWithNewKey();
apiLogger.warn("Encryption key regenerated via API", { apiLogger.warn("Encryption key regenerated via API", {
operation: "encryption_regenerate_api" operation: "encryption_regenerate_api",
}); });
res.json({ res.json({
success: true, success: true,
message: "New encryption key generated", message: "New encryption key generated",
warning: "All encrypted data must be re-encrypted" warning: "All encrypted data must be re-encrypted",
}); });
} catch (error) { } catch (error) {
apiLogger.error("Failed to regenerate encryption key", error, { apiLogger.error("Failed to regenerate encryption key", error, {
operation: "encryption_regenerate_failed" operation: "encryption_regenerate_failed",
}); });
res.status(500).json({ error: "Failed to regenerate encryption key" }); res.status(500).json({ error: "Failed to regenerate encryption key" });
} }
@@ -400,7 +405,7 @@ app.post("/database/export", async (req, res) => {
apiLogger.info("Starting SQLite database export via API", { apiLogger.info("Starting SQLite database export via API", {
operation: "database_sqlite_export_api", operation: "database_sqlite_export_api",
customPath: !!customPath customPath: !!customPath,
}); });
const exportPath = await DatabaseSQLiteExport.exportDatabase(customPath); const exportPath = await DatabaseSQLiteExport.exportDatabase(customPath);
@@ -410,20 +415,20 @@ app.post("/database/export", async (req, res) => {
message: "Database exported successfully as SQLite", message: "Database exported successfully as SQLite",
exportPath, exportPath,
size: fs.statSync(exportPath).size, size: fs.statSync(exportPath).size,
format: "sqlite" format: "sqlite",
}); });
} catch (error) { } catch (error) {
apiLogger.error("SQLite database export failed", error, { apiLogger.error("SQLite database export failed", error, {
operation: "database_sqlite_export_api_failed" operation: "database_sqlite_export_api_failed",
}); });
res.status(500).json({ res.status(500).json({
error: "SQLite database export failed", error: "SQLite database export failed",
details: error instanceof Error ? error.message : "Unknown error" details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
app.post("/database/import", upload.single('file'), async (req, res) => { app.post("/database/import", upload.single("file"), async (req, res) => {
try { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: "No file uploaded" }); return res.status(400).json({ error: "No file uploaded" });
@@ -439,17 +444,17 @@ app.post("/database/import", upload.single('file'), async (req, res) => {
originalName: req.file.originalname, originalName: req.file.originalname,
fileSize: req.file.size, fileSize: req.file.size,
mode: "additive", mode: "additive",
backupCurrent: backupCurrentBool backupCurrent: backupCurrentBool,
}); });
// Validate export file first // Validate export file first
// Check file extension using original filename // Check file extension using original filename
if (!req.file.originalname.endsWith('.termix-export.sqlite')) { if (!req.file.originalname.endsWith(".termix-export.sqlite")) {
// Clean up uploaded file // Clean up uploaded file
fs.unlinkSync(importPath); fs.unlinkSync(importPath);
return res.status(400).json({ return res.status(400).json({
error: "Invalid SQLite export file", error: "Invalid SQLite export file",
details: ["File must have .termix-export.sqlite extension"] details: ["File must have .termix-export.sqlite extension"],
}); });
} }
@@ -459,13 +464,13 @@ app.post("/database/import", upload.single('file'), async (req, res) => {
fs.unlinkSync(importPath); fs.unlinkSync(importPath);
return res.status(400).json({ return res.status(400).json({
error: "Invalid SQLite export file", error: "Invalid SQLite export file",
details: validation.errors details: validation.errors,
}); });
} }
const result = await DatabaseSQLiteExport.importDatabase(importPath, { const result = await DatabaseSQLiteExport.importDatabase(importPath, {
replaceExisting: false, // Always use additive mode replaceExisting: false, // Always use additive mode
backupCurrent: backupCurrentBool backupCurrent: backupCurrentBool,
}); });
// Clean up uploaded file // Clean up uploaded file
@@ -473,11 +478,13 @@ app.post("/database/import", upload.single('file'), async (req, res) => {
res.json({ res.json({
success: result.success, success: result.success,
message: result.success ? "SQLite database imported successfully" : "SQLite database import completed with errors", message: result.success
? "SQLite database imported successfully"
: "SQLite database import completed with errors",
imported: result.imported, imported: result.imported,
errors: result.errors, errors: result.errors,
warnings: result.warnings, warnings: result.warnings,
format: "sqlite" format: "sqlite",
}); });
} catch (error) { } catch (error) {
// Clean up uploaded file if it exists // Clean up uploaded file if it exists
@@ -488,17 +495,20 @@ app.post("/database/import", upload.single('file'), async (req, res) => {
apiLogger.warn("Failed to clean up uploaded file", { apiLogger.warn("Failed to clean up uploaded file", {
operation: "file_cleanup_failed", operation: "file_cleanup_failed",
filePath: req.file.path, filePath: req.file.path,
error: cleanupError instanceof Error ? cleanupError.message : 'Unknown error' error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
}); });
} }
} }
apiLogger.error("SQLite database import failed", error, { apiLogger.error("SQLite database import failed", error, {
operation: "database_sqlite_import_api_failed" operation: "database_sqlite_import_api_failed",
}); });
res.status(500).json({ res.status(500).json({
error: "SQLite database import failed", error: "SQLite database import failed",
details: error instanceof Error ? error.message : "Unknown error" details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
@@ -512,18 +522,18 @@ app.get("/database/export/:exportPath/info", async (req, res) => {
if (!validation.valid) { if (!validation.valid) {
return res.status(400).json({ return res.status(400).json({
error: "Invalid SQLite export file", error: "Invalid SQLite export file",
details: validation.errors details: validation.errors,
}); });
} }
res.json({ res.json({
valid: true, valid: true,
metadata: validation.metadata, metadata: validation.metadata,
format: "sqlite" format: "sqlite",
}); });
} catch (error) { } catch (error) {
apiLogger.error("Failed to get SQLite export info", error, { apiLogger.error("Failed to get SQLite export info", error, {
operation: "sqlite_export_info_failed" operation: "sqlite_export_info_failed",
}); });
res.status(500).json({ error: "Failed to get SQLite export information" }); res.status(500).json({ error: "Failed to get SQLite export information" });
} }
@@ -534,23 +544,26 @@ app.post("/database/backup", async (req, res) => {
const { customPath } = req.body; const { customPath } = req.body;
apiLogger.info("Creating encrypted database backup via API", { apiLogger.info("Creating encrypted database backup via API", {
operation: "database_backup_api" operation: "database_backup_api",
}); });
// Import required modules // Import required modules
const { databasePaths, getMemoryDatabaseBuffer } = await import("./db/index.js"); const { databasePaths, getMemoryDatabaseBuffer } = await import(
"./db/index.js"
);
// Get current in-memory database as buffer // Get current in-memory database as buffer
const dbBuffer = getMemoryDatabaseBuffer(); const dbBuffer = getMemoryDatabaseBuffer();
// Create backup directory // Create backup directory
const backupDir = customPath || path.join(databasePaths.directory, 'backups'); const backupDir =
customPath || path.join(databasePaths.directory, "backups");
if (!fs.existsSync(backupDir)) { if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true }); fs.mkdirSync(backupDir, { recursive: true });
} }
// Generate backup filename with timestamp // Generate backup filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`; const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
const backupPath = path.join(backupDir, backupFileName); const backupPath = path.join(backupDir, backupFileName);
@@ -561,15 +574,15 @@ app.post("/database/backup", async (req, res) => {
success: true, success: true,
message: "Encrypted backup created successfully", message: "Encrypted backup created successfully",
backupPath, backupPath,
size: fs.statSync(backupPath).size size: fs.statSync(backupPath).size,
}); });
} catch (error) { } catch (error) {
apiLogger.error("Database backup failed", error, { apiLogger.error("Database backup failed", error, {
operation: "database_backup_api_failed" operation: "database_backup_api_failed",
}); });
res.status(500).json({ res.status(500).json({
error: "Database backup failed", error: "Database backup failed",
details: error instanceof Error ? error.message : "Unknown error" details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
@@ -584,7 +597,7 @@ app.post("/database/restore", async (req, res) => {
apiLogger.info("Restoring database from backup via API", { apiLogger.info("Restoring database from backup via API", {
operation: "database_restore_api", operation: "database_restore_api",
backupPath backupPath,
}); });
// Validate backup file // Validate backup file
@@ -596,24 +609,28 @@ app.post("/database/restore", async (req, res) => {
if (!DatabaseFileEncryption.validateHardwareCompatibility(backupPath)) { if (!DatabaseFileEncryption.validateHardwareCompatibility(backupPath)) {
return res.status(400).json({ return res.status(400).json({
error: "Hardware fingerprint mismatch", error: "Hardware fingerprint mismatch",
message: "This backup was created on different hardware and cannot be restored" message:
"This backup was created on different hardware and cannot be restored",
}); });
} }
const restoredPath = DatabaseFileEncryption.restoreFromEncryptedBackup(backupPath, targetPath); const restoredPath = DatabaseFileEncryption.restoreFromEncryptedBackup(
backupPath,
targetPath,
);
res.json({ res.json({
success: true, success: true,
message: "Database restored successfully", message: "Database restored successfully",
restoredPath restoredPath,
}); });
} catch (error) { } catch (error) {
apiLogger.error("Database restore failed", error, { apiLogger.error("Database restore failed", error, {
operation: "database_restore_api_failed" operation: "database_restore_api_failed",
}); });
res.status(500).json({ res.status(500).json({
error: "Database restore failed", error: "Database restore failed",
details: error instanceof Error ? error.message : "Unknown error" details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
@@ -645,13 +662,13 @@ const PORT = 8081;
async function initializeEncryption() { async function initializeEncryption() {
try { try {
databaseLogger.info("Initializing database encryption...", { databaseLogger.info("Initializing database encryption...", {
operation: "encryption_init" operation: "encryption_init",
}); });
await DatabaseEncryption.initialize({ await DatabaseEncryption.initialize({
encryptionEnabled: process.env.ENCRYPTION_ENABLED !== 'false', encryptionEnabled: process.env.ENCRYPTION_ENABLED !== "false",
forceEncryption: process.env.FORCE_ENCRYPTION === 'true', forceEncryption: process.env.FORCE_ENCRYPTION === "true",
migrateOnAccess: process.env.MIGRATE_ON_ACCESS !== 'false' migrateOnAccess: process.env.MIGRATE_ON_ACCESS !== "false",
}); });
const status = await DatabaseEncryption.getDetailedStatus(); const status = await DatabaseEncryption.getDetailedStatus();
@@ -660,24 +677,28 @@ async function initializeEncryption() {
operation: "encryption_init_complete", operation: "encryption_init_complete",
enabled: status.enabled, enabled: status.enabled,
keyId: status.key.keyId, keyId: status.key.keyId,
hasStoredKey: status.key.hasKey hasStoredKey: status.key.hasKey,
}); });
} else { } else {
databaseLogger.error("Database encryption configuration invalid", undefined, { databaseLogger.error(
"Database encryption configuration invalid",
undefined,
{
operation: "encryption_init_failed", operation: "encryption_init_failed",
status status,
}); },
);
} }
} catch (error) { } catch (error) {
databaseLogger.error("Failed to initialize database encryption", error, { databaseLogger.error("Failed to initialize database encryption", error, {
operation: "encryption_init_error" operation: "encryption_init_error",
}); });
} }
} }
app.listen(PORT, async () => { app.listen(PORT, async () => {
// Ensure uploads directory exists // Ensure uploads directory exists
const uploadsDir = path.join(process.cwd(), 'uploads'); const uploadsDir = path.join(process.cwd(), "uploads");
if (!fs.existsSync(uploadsDir)) { if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
} }

View File

@@ -17,12 +17,12 @@ if (!fs.existsSync(dbDir)) {
} }
// Database file encryption configuration // Database file encryption configuration
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== 'false'; 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`; const encryptedDbPath = `${dbPath}.encrypted`;
// Initialize database with file encryption support // Initialize database with file encryption support
let actualDbPath = ':memory:'; // Always use memory database let actualDbPath = ":memory:"; // Always use memory database
let memoryDatabase: Database.Database; let memoryDatabase: Database.Database;
let isNewDatabase = false; let isNewDatabase = false;
@@ -30,55 +30,54 @@ if (enableFileEncryption) {
try { try {
// Check if encrypted database exists // Check if encrypted database exists
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) { if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
databaseLogger.info('Found encrypted database file, loading into memory...', { databaseLogger.info(
operation: 'db_memory_load', "Found encrypted database file, loading into memory...",
encryptedPath: encryptedDbPath {
}); operation: "db_memory_load",
encryptedPath: encryptedDbPath,
},
);
// Validate hardware compatibility // Validate hardware compatibility
if (!DatabaseFileEncryption.validateHardwareCompatibility(encryptedDbPath)) { if (
databaseLogger.error('Hardware fingerprint mismatch for encrypted database', { !DatabaseFileEncryption.validateHardwareCompatibility(encryptedDbPath)
operation: 'db_decrypt_failed', ) {
reason: 'hardware_mismatch' databaseLogger.error(
}); "Hardware fingerprint mismatch for encrypted database",
throw new Error('Cannot decrypt database: hardware fingerprint mismatch'); {
operation: "db_decrypt_failed",
reason: "hardware_mismatch",
},
);
throw new Error(
"Cannot decrypt database: hardware fingerprint mismatch",
);
} }
// Decrypt database content to memory buffer // Decrypt database content to memory buffer
const decryptedBuffer = DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath); const decryptedBuffer =
DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
// Create in-memory database from decrypted buffer // Create in-memory database from decrypted buffer
memoryDatabase = new Database(decryptedBuffer); memoryDatabase = new Database(decryptedBuffer);
databaseLogger.success('Existing database loaded into memory successfully', {
operation: 'db_memory_load_success',
bufferSize: decryptedBuffer.length,
inMemory: true
});
} else { } else {
// No encrypted database exists - create new in-memory database memoryDatabase = new Database(":memory:");
databaseLogger.info('No encrypted database found, creating new in-memory database', {
operation: 'db_memory_create_new'
});
memoryDatabase = new Database(':memory:');
isNewDatabase = true; isNewDatabase = true;
// Check if there's an old unencrypted database to migrate // Check if there's an old unencrypted database to migrate
if (fs.existsSync(dbPath)) { 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 // Load old database and copy its content to memory database
const oldDb = new Database(dbPath, { readonly: true }); const oldDb = new Database(dbPath, { readonly: true });
// Get all table schemas and data from old database // Get all table schemas and data from old database
const tables = oldDb.prepare(` const tables = oldDb
.prepare(
`
SELECT name, sql FROM sqlite_master SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%' WHERE type='table' AND name NOT LIKE 'sqlite_%'
`).all() as { name: string; sql: string }[]; `,
)
.all() as { name: string; sql: string }[];
// Create tables in memory database // Create tables in memory database
for (const table of tables) { for (const table of tables) {
@@ -90,13 +89,13 @@ if (enableFileEncryption) {
const rows = oldDb.prepare(`SELECT * FROM ${table.name}`).all(); const rows = oldDb.prepare(`SELECT * FROM ${table.name}`).all();
if (rows.length > 0) { if (rows.length > 0) {
const columns = Object.keys(rows[0]); const columns = Object.keys(rows[0]);
const placeholders = columns.map(() => '?').join(', '); const placeholders = columns.map(() => "?").join(", ");
const insertStmt = memoryDatabase.prepare( const insertStmt = memoryDatabase.prepare(
`INSERT INTO ${table.name} (${columns.join(', ')}) VALUES (${placeholders})` `INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
); );
for (const row of rows) { for (const row of rows) {
const values = columns.map(col => (row as any)[col]); const values = columns.map((col) => (row as any)[col]);
insertStmt.run(values); insertStmt.run(values);
} }
} }
@@ -104,48 +103,36 @@ if (enableFileEncryption) {
oldDb.close(); oldDb.close();
databaseLogger.success('Migrated existing database to memory', {
operation: 'db_migrate_to_memory_success'
});
isNewDatabase = false; isNewDatabase = false;
} else { } else {
databaseLogger.success('Created new in-memory database', {
operation: 'db_memory_create_success'
});
} }
} }
} catch (error) { } catch (error) {
databaseLogger.error('Failed to initialize memory database', error, { databaseLogger.error("Failed to initialize memory database", error, {
operation: 'db_memory_init_failed' operation: "db_memory_init_failed",
}); });
// If file encryption is critical, fail fast // If file encryption is critical, fail fast
if (process.env.DB_FILE_ENCRYPTION_REQUIRED === 'true') { if (process.env.DB_FILE_ENCRYPTION_REQUIRED === "true") {
throw error; throw error;
} }
// Create fallback in-memory database memoryDatabase = new Database(":memory:");
databaseLogger.warn('Creating fallback in-memory database', {
operation: 'db_memory_fallback'
});
memoryDatabase = new Database(':memory:');
isNewDatabase = true; isNewDatabase = true;
} }
} else { } else {
// File encryption disabled - still use memory for consistency memoryDatabase = new Database(":memory:");
databaseLogger.info('File encryption disabled, using in-memory database', {
operation: 'db_memory_no_encryption'
});
memoryDatabase = new Database(':memory:');
isNewDatabase = true; isNewDatabase = true;
} }
databaseLogger.info(`Initializing SQLite database`, { databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init", operation: "db_init",
path: actualDbPath, path: actualDbPath,
encrypted: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), encrypted:
enableFileEncryption &&
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
inMemory: true, inMemory: true,
isNewDatabase isNewDatabase,
}); });
const sqlite = memoryDatabase; const sqlite = memoryDatabase;
@@ -415,13 +402,7 @@ const initializeDatabase = async (): Promise<void> => {
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')", "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
) )
.run(); .run();
databaseLogger.success("Default settings initialized", {
operation: "db_init",
});
} else { } else {
databaseLogger.debug("Default settings already exist", {
operation: "db_init",
});
} }
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize default settings", { databaseLogger.warn("Could not initialize default settings", {
@@ -442,14 +423,14 @@ async function saveMemoryDatabaseToFile() {
// Encrypt and save to file // Encrypt and save to file
DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath); DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
databaseLogger.debug('In-memory database saved to encrypted file', { databaseLogger.debug("In-memory database saved to encrypted file", {
operation: 'memory_db_save', operation: "memory_db_save",
bufferSize: buffer.length, bufferSize: buffer.length,
encryptedPath: encryptedDbPath encryptedPath: encryptedDbPath,
}); });
} catch (error) { } catch (error) {
databaseLogger.error('Failed to save in-memory database', error, { databaseLogger.error("Failed to save in-memory database", error, {
operation: 'memory_db_save_failed' operation: "memory_db_save_failed",
}); });
} }
} }
@@ -461,39 +442,55 @@ async function handlePostInitFileEncryption() {
try { try {
// Clean up any existing unencrypted database files // Clean up any existing unencrypted database files
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
databaseLogger.warn('Found unencrypted database file, removing for security', { databaseLogger.warn(
operation: 'db_security_cleanup_existing', "Found unencrypted database file, removing for security",
removingPath: dbPath {
}); operation: "db_security_cleanup_existing",
removingPath: dbPath,
},
);
try { try {
fs.unlinkSync(dbPath); fs.unlinkSync(dbPath);
databaseLogger.success('Unencrypted database file removed for security', { databaseLogger.success(
operation: 'db_security_cleanup_complete', "Unencrypted database file removed for security",
removedPath: dbPath {
}); operation: "db_security_cleanup_complete",
removedPath: dbPath,
},
);
} catch (error) { } catch (error) {
databaseLogger.warn('Could not remove unencrypted database file (may be locked)', { databaseLogger.warn(
operation: 'db_security_cleanup_deferred', "Could not remove unencrypted database file (may be locked)",
{
operation: "db_security_cleanup_deferred",
path: dbPath, path: dbPath,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}); },
);
// Try again after a short delay // Try again after a short delay
setTimeout(() => { setTimeout(() => {
try { try {
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath); fs.unlinkSync(dbPath);
databaseLogger.success('Delayed cleanup: unencrypted database file removed', { databaseLogger.success(
operation: 'db_security_cleanup_delayed_success', "Delayed cleanup: unencrypted database file removed",
removedPath: dbPath {
}); operation: "db_security_cleanup_delayed_success",
removedPath: dbPath,
},
);
} }
} catch (delayedError) { } catch (delayedError) {
databaseLogger.error('Failed to remove unencrypted database file even after delay', delayedError, { databaseLogger.error(
operation: 'db_security_cleanup_delayed_failed', "Failed to remove unencrypted database file even after delay",
path: dbPath delayedError,
}); {
operation: "db_security_cleanup_delayed_failed",
path: dbPath,
},
);
} }
}, 2000); }, 2000);
} }
@@ -506,16 +503,15 @@ async function handlePostInitFileEncryption() {
// Set up periodic saves every 5 minutes // Set up periodic saves every 5 minutes
setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000); setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000);
databaseLogger.info('Periodic in-memory database saves configured', {
operation: 'memory_db_autosave_setup',
intervalMinutes: 5
});
} }
} catch (error) { } catch (error) {
databaseLogger.error('Failed to handle database file encryption/cleanup', error, { databaseLogger.error(
operation: 'db_encrypt_cleanup_failed' "Failed to handle database file encryption/cleanup",
}); error,
{
operation: "db_encrypt_cleanup_failed",
},
);
// Don't fail the entire initialization for this // Don't fail the entire initialization for this
} }
@@ -533,7 +529,9 @@ initializeDatabase()
databaseLogger.success("Database connection established", { databaseLogger.success("Database connection established", {
operation: "db_init", operation: "db_init",
path: actualDbPath, path: actualDbPath,
hasEncryptedBackup: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath) hasEncryptedBackup:
enableFileEncryption &&
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
}); });
// Cleanup function for database and temporary files // Cleanup function for database and temporary files
@@ -542,13 +540,14 @@ async function cleanupDatabase() {
if (memoryDatabase) { if (memoryDatabase) {
try { try {
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
databaseLogger.info('In-memory database saved before shutdown', {
operation: 'shutdown_save'
});
} catch (error) { } catch (error) {
databaseLogger.error('Failed to save in-memory database before shutdown', error, { databaseLogger.error(
operation: 'shutdown_save_failed' "Failed to save in-memory database before shutdown",
}); error,
{
operation: "shutdown_save_failed",
},
);
} }
} }
@@ -556,20 +555,20 @@ async function cleanupDatabase() {
try { try {
if (sqlite) { if (sqlite) {
sqlite.close(); sqlite.close();
databaseLogger.debug('Database connection closed', { databaseLogger.debug("Database connection closed", {
operation: 'db_close' operation: "db_close",
}); });
} }
} catch (error) { } catch (error) {
databaseLogger.warn('Error closing database connection', { databaseLogger.warn("Error closing database connection", {
operation: 'db_close_error', operation: "db_close_error",
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}); });
} }
// Clean up temp directory // Clean up temp directory
try { try {
const tempDir = path.join(dataDir, '.temp'); const tempDir = path.join(dataDir, ".temp");
if (fs.existsSync(tempDir)) { if (fs.existsSync(tempDir)) {
const files = fs.readdirSync(tempDir); const files = fs.readdirSync(tempDir);
for (const file of files) { for (const file of files) {
@@ -582,8 +581,8 @@ async function cleanupDatabase() {
try { try {
fs.rmdirSync(tempDir); fs.rmdirSync(tempDir);
databaseLogger.debug('Temp directory cleaned up', { databaseLogger.debug("Temp directory cleaned up", {
operation: 'temp_dir_cleanup' operation: "temp_dir_cleanup",
}); });
} catch { } catch {
// Ignore directory removal errors // Ignore directory removal errors
@@ -595,7 +594,7 @@ async function cleanupDatabase() {
} }
// Register cleanup handlers // Register cleanup handlers
process.on('exit', () => { process.on("exit", () => {
// Synchronous cleanup only for exit event // Synchronous cleanup only for exit event
if (sqlite) { if (sqlite) {
try { try {
@@ -604,17 +603,17 @@ process.on('exit', () => {
} }
}); });
process.on('SIGINT', async () => { process.on("SIGINT", async () => {
databaseLogger.info('Received SIGINT, cleaning up...', { databaseLogger.info("Received SIGINT, cleaning up...", {
operation: 'shutdown' operation: "shutdown",
}); });
await cleanupDatabase(); await cleanupDatabase();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', async () => { process.on("SIGTERM", async () => {
databaseLogger.info('Received SIGTERM, cleaning up...', { databaseLogger.info("Received SIGTERM, cleaning up...", {
operation: 'shutdown' operation: "shutdown",
}); });
await cleanupDatabase(); await cleanupDatabase();
process.exit(0); process.exit(0);
@@ -628,29 +627,33 @@ export const databasePaths = {
main: actualDbPath, main: actualDbPath,
encrypted: encryptedDbPath, encrypted: encryptedDbPath,
directory: dbDir, directory: dbDir,
inMemory: true inMemory: true,
}; };
// Memory database buffer function // Memory database buffer function
function getMemoryDatabaseBuffer(): Buffer { function getMemoryDatabaseBuffer(): Buffer {
if (!memoryDatabase) { if (!memoryDatabase) {
throw new Error('Memory database not initialized'); throw new Error("Memory database not initialized");
} }
try { try {
// Export in-memory database to buffer // Export in-memory database to buffer
const buffer = memoryDatabase.serialize(); const buffer = memoryDatabase.serialize();
databaseLogger.debug('Memory database serialized to buffer', { databaseLogger.debug("Memory database serialized to buffer", {
operation: 'memory_db_serialize', operation: "memory_db_serialize",
bufferSize: buffer.length bufferSize: buffer.length,
}); });
return buffer; return buffer;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to serialize memory database to buffer', error, { databaseLogger.error(
operation: 'memory_db_serialize_failed' "Failed to serialize memory database to buffer",
}); error,
{
operation: "memory_db_serialize_failed",
},
);
throw error; throw error;
} }
} }

View File

@@ -6,53 +6,68 @@ 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 { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js"; import {
parseSSHKey,
parsePublicKey,
detectKeyType,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import crypto from "crypto"; import crypto from "crypto";
import ssh2Pkg from "ssh2"; import ssh2Pkg from "ssh2";
const { utils: ssh2Utils, Client } = ssh2Pkg; const { utils: ssh2Utils, Client } = ssh2Pkg;
// Direct SSH key generation with ssh2 - the right way // 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 } { function generateSSHKeyPair(
console.log('Generating SSH key pair with ssh2:', keyType); keyType: string,
keySize?: number,
passphrase?: string,
): {
success: boolean;
privateKey?: string;
publicKey?: string;
error?: string;
} {
console.log("Generating SSH key pair with ssh2:", keyType);
try { try {
// Convert our keyType to ssh2 format // Convert our keyType to ssh2 format
let ssh2Type = keyType; let ssh2Type = keyType;
const options: any = {}; const options: any = {};
if (keyType === 'ssh-rsa') { if (keyType === "ssh-rsa") {
ssh2Type = 'rsa'; ssh2Type = "rsa";
options.bits = keySize || 2048; options.bits = keySize || 2048;
} else if (keyType === 'ssh-ed25519') { } else if (keyType === "ssh-ed25519") {
ssh2Type = 'ed25519'; ssh2Type = "ed25519";
} else if (keyType === 'ecdsa-sha2-nistp256') { } else if (keyType === "ecdsa-sha2-nistp256") {
ssh2Type = 'ecdsa'; ssh2Type = "ecdsa";
options.bits = 256; // ECDSA P-256 uses 256 bits options.bits = 256; // ECDSA P-256 uses 256 bits
} }
// Add passphrase protection if provided // Add passphrase protection if provided
if (passphrase && passphrase.trim()) { if (passphrase && passphrase.trim()) {
options.passphrase = passphrase; options.passphrase = passphrase;
options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys options.cipher = "aes128-cbc"; // Default cipher for encrypted private keys
} }
// Use ssh2's native key generation // Use ssh2's native key generation
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
console.log('SSH key pair generated successfully!'); console.log("SSH key pair generated successfully!");
console.log('Private key length:', keyPair.private.length); console.log("Private key length:", keyPair.private.length);
console.log('Public key preview:', keyPair.public.substring(0, 50) + '...'); console.log("Public key preview:", keyPair.public.substring(0, 50) + "...");
return { return {
success: true, success: true,
privateKey: keyPair.private, privateKey: keyPair.private,
publicKey: keyPair.public publicKey: keyPair.public,
}; };
} catch (error) { } catch (error) {
console.error('SSH key generation failed:', error); console.error("SSH key generation failed:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'SSH key generation failed' error:
error instanceof Error ? error.message : "SSH key generation failed",
}; };
} }
} }
@@ -171,7 +186,7 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
error: keyInfo.error, error: keyInfo.error,
}); });
return res.status(400).json({ return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}` error: `Invalid SSH key: ${keyInfo.error}`,
}); });
} }
} }
@@ -195,11 +210,11 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
lastUsed: null, lastUsed: null,
}; };
const created = await EncryptedDBOperations.insert( const created = (await EncryptedDBOperations.insert(
sshCredentials, sshCredentials,
'ssh_credentials', "ssh_credentials",
credentialData credentialData,
) as typeof credentialData & { id: number }; )) 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}`,
@@ -240,8 +255,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
try { try {
const credentials = await EncryptedDBOperations.select( const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(eq(sshCredentials.userId, userId)).orderBy(desc(sshCredentials.updatedAt)), db
'ssh_credentials' .select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt)),
"ssh_credentials",
); );
res.json(credentials.map((cred) => formatCredentialOutput(cred))); res.json(credentials.map((cred) => formatCredentialOutput(cred)));
@@ -297,11 +316,16 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
try { try {
const credentials = await EncryptedDBOperations.select( const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(and( db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
)), ),
'ssh_credentials' ),
"ssh_credentials",
); );
if (credentials.length === 0) { if (credentials.length === 0) {
@@ -400,7 +424,7 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
error: keyInfo.error, error: keyInfo.error,
}); });
return res.status(400).json({ return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}` error: `Invalid SSH key: ${keyInfo.error}`,
}); });
} }
updateFields.privateKey = keyInfo.privateKey; updateFields.privateKey = keyInfo.privateKey;
@@ -414,8 +438,11 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
if (Object.keys(updateFields).length === 0) { if (Object.keys(updateFields).length === 0) {
const existing = await EncryptedDBOperations.select( const existing = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))), db
'ssh_credentials' .select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
); );
return res.json(formatCredentialOutput(existing[0])); return res.json(formatCredentialOutput(existing[0]));
@@ -423,17 +450,20 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
await EncryptedDBOperations.update( await EncryptedDBOperations.update(
sshCredentials, sshCredentials,
'ssh_credentials', "ssh_credentials",
and( and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
), ),
updateFields updateFields,
); );
const updated = await EncryptedDBOperations.select( const updated = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))), db
'ssh_credentials' .select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
); );
const credential = updated[0]; const credential = updated[0];
@@ -757,7 +787,10 @@ router.put(
// Detect SSH key type endpoint // Detect SSH key type endpoint
// POST /credentials/detect-key-type // POST /credentials/detect-key-type
router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Response) => { router.post(
"/detect-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body; const { privateKey, keyPassword } = req.body;
console.log("=== Key Detection API Called ==="); console.log("=== Key Detection API Called ===");
@@ -780,7 +813,7 @@ router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Respo
keyType: keyInfo.keyType, keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType, detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey, hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null error: keyInfo.error || null,
}; };
console.log("Sending response:", response); console.log("Sending response:", response);
@@ -789,14 +822,19 @@ router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Respo
console.error("Exception in detect-key-type endpoint:", error); console.error("Exception in detect-key-type endpoint:", error);
authLogger.error("Failed to detect key type", error); authLogger.error("Failed to detect key type", error);
res.status(500).json({ res.status(500).json({
error: error instanceof Error ? error.message : "Failed to detect key type" error:
error instanceof Error ? error.message : "Failed to detect key type",
}); });
} }
}); },
);
// Detect SSH public key type endpoint // Detect SSH public key type endpoint
// POST /credentials/detect-public-key-type // POST /credentials/detect-public-key-type
router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => { router.post(
"/detect-public-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { publicKey } = req.body; const { publicKey } = req.body;
console.log("=== Public Key Detection API Called ==="); console.log("=== Public Key Detection API Called ===");
@@ -818,7 +856,7 @@ router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res
success: keyInfo.success, success: keyInfo.success,
keyType: keyInfo.keyType, keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType, detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null error: keyInfo.error || null,
}; };
console.log("Sending response:", response); console.log("Sending response:", response);
@@ -827,14 +865,21 @@ router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res
console.error("Exception in detect-public-key-type endpoint:", error); console.error("Exception in detect-public-key-type endpoint:", error);
authLogger.error("Failed to detect public key type", error); authLogger.error("Failed to detect public key type", error);
res.status(500).json({ res.status(500).json({
error: error instanceof Error ? error.message : "Failed to detect public key type" error:
error instanceof Error
? error.message
: "Failed to detect public key type",
}); });
} }
}); },
);
// Validate SSH key pair endpoint // Validate SSH key pair endpoint
// POST /credentials/validate-key-pair // POST /credentials/validate-key-pair
router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => { router.post(
"/validate-key-pair",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body; const { privateKey, publicKey, keyPassword } = req.body;
console.log("=== Key Pair Validation API Called ==="); console.log("=== Key Pair Validation API Called ===");
@@ -854,7 +899,11 @@ router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Res
try { try {
console.log("Calling validateKeyPair..."); console.log("Calling validateKeyPair...");
const validationResult = validateKeyPair(privateKey, publicKey, keyPassword); const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
console.log("validateKeyPair result:", validationResult); console.log("validateKeyPair result:", validationResult);
const response = { const response = {
@@ -862,7 +911,7 @@ router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Res
privateKeyType: validationResult.privateKeyType, privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType, publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey, generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null error: validationResult.error || null,
}; };
console.log("Sending response:", response); console.log("Sending response:", response);
@@ -871,15 +920,22 @@ router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Res
console.error("Exception in validate-key-pair endpoint:", error); console.error("Exception in validate-key-pair endpoint:", error);
authLogger.error("Failed to validate key pair", error); authLogger.error("Failed to validate key pair", error);
res.status(500).json({ res.status(500).json({
error: error instanceof Error ? error.message : "Failed to validate key pair" error:
error instanceof Error
? error.message
: "Failed to validate key pair",
}); });
} }
}); },
);
// Generate new SSH key pair endpoint // Generate new SSH key pair endpoint
// POST /credentials/generate-key-pair // POST /credentials/generate-key-pair
router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => { router.post(
const { keyType = 'ssh-ed25519', keySize = 2048, passphrase } = req.body; "/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("=== Generate Key Pair API Called ===");
console.log("Key type:", keyType); console.log("Key type:", keyType);
@@ -896,10 +952,10 @@ router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Res
privateKey: result.privateKey, privateKey: result.privateKey,
publicKey: result.publicKey, publicKey: result.publicKey,
keyType: keyType, keyType: keyType,
format: 'ssh', format: "ssh",
algorithm: keyType, algorithm: keyType,
keySize: keyType === 'ssh-rsa' ? keySize : undefined, keySize: keyType === "ssh-rsa" ? keySize : undefined,
curve: keyType === 'ecdsa-sha2-nistp256' ? 'nistp256' : undefined curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
}; };
console.log("SSH key pair generated successfully:", keyType); console.log("SSH key pair generated successfully:", keyType);
@@ -908,7 +964,7 @@ router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Res
console.error("SSH key generation failed:", result.error); console.error("SSH key generation failed:", result.error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: result.error || "Failed to generate SSH key pair" error: result.error || "Failed to generate SSH key pair",
}); });
} }
} catch (error) { } catch (error) {
@@ -916,14 +972,21 @@ router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Res
authLogger.error("Failed to generate key pair", error); authLogger.error("Failed to generate key pair", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error instanceof Error ? error.message : "Failed to generate key pair" error:
error instanceof Error
? error.message
: "Failed to generate key pair",
}); });
} }
}); },
);
// Generate public key from private key endpoint // Generate public key from private key endpoint
// POST /credentials/generate-public-key // POST /credentials/generate-public-key
router.post("/generate-public-key", authenticateJWT, async (req: Request, res: Response) => { router.post(
"/generate-public-key",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body; const { privateKey, keyPassword } = req.body;
console.log("=== Generate Public Key API Called ==="); console.log("=== Generate Public Key API Called ===");
@@ -937,7 +1000,9 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
} }
try { try {
console.log("Using Node.js crypto to generate public key from private key..."); console.log(
"Using Node.js crypto to generate public key from private key...",
);
console.log("Private key length:", privateKey.length); console.log("Private key length:", privateKey.length);
console.log("Private key first 100 chars:", privateKey.substring(0, 100)); console.log("Private key first 100 chars:", privateKey.substring(0, 100));
@@ -949,7 +1014,7 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
try { try {
privateKeyObj = crypto.createPrivateKey({ privateKeyObj = crypto.createPrivateKey({
key: privateKey, key: privateKey,
passphrase: keyPassword passphrase: keyPassword,
}); });
console.log("Successfully parsed with passphrase method"); console.log("Successfully parsed with passphrase method");
} catch (error) { } catch (error) {
@@ -971,8 +1036,8 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
try { try {
privateKeyObj = crypto.createPrivateKey({ privateKeyObj = crypto.createPrivateKey({
key: privateKey, key: privateKey,
format: 'pem', format: "pem",
type: 'pkcs8' type: "pkcs8",
}); });
console.log("Successfully parsed as PKCS#8"); console.log("Successfully parsed as PKCS#8");
} catch (error) { } catch (error) {
@@ -981,12 +1046,15 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
} }
// Attempt 4: Try as PKCS#1 RSA // Attempt 4: Try as PKCS#1 RSA
if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) { if (
!privateKeyObj &&
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
) {
try { try {
privateKeyObj = crypto.createPrivateKey({ privateKeyObj = crypto.createPrivateKey({
key: privateKey, key: privateKey,
format: 'pem', format: "pem",
type: 'pkcs1' type: "pkcs1",
}); });
console.log("Successfully parsed as PKCS#1 RSA"); console.log("Successfully parsed as PKCS#1 RSA");
} catch (error) { } catch (error) {
@@ -995,12 +1063,15 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
} }
// Attempt 5: Try as SEC1 EC // Attempt 5: Try as SEC1 EC
if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) { if (
!privateKeyObj &&
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
) {
try { try {
privateKeyObj = crypto.createPrivateKey({ privateKeyObj = crypto.createPrivateKey({
key: privateKey, key: privateKey,
format: 'pem', format: "pem",
type: 'sec1' type: "sec1",
}); });
console.log("Successfully parsed as SEC1 EC"); console.log("Successfully parsed as SEC1 EC");
} catch (error) { } catch (error) {
@@ -1018,16 +1089,24 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
if (keyInfo.success && keyInfo.publicKey) { if (keyInfo.success && keyInfo.publicKey) {
// Ensure SSH2 fallback also returns proper string // Ensure SSH2 fallback also returns proper string
const publicKeyString = String(keyInfo.publicKey); const publicKeyString = String(keyInfo.publicKey);
console.log("SSH2 fallback public key type:", typeof publicKeyString); console.log(
console.log("SSH2 fallback public key length:", publicKeyString.length); "SSH2 fallback public key type:",
typeof publicKeyString,
);
console.log(
"SSH2 fallback public key length:",
publicKeyString.length,
);
return res.json({ return res.json({
success: true, success: true,
publicKey: publicKeyString, publicKey: publicKeyString,
keyType: keyInfo.keyType keyType: keyInfo.keyType,
}); });
} else { } else {
parseAttempts.push(`SSH2 fallback: ${keyInfo.error || 'No public key generated'}`); parseAttempts.push(
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
);
} }
} catch (error) { } catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`); parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
@@ -1039,53 +1118,65 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Unable to parse private key. Tried multiple formats.", error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts details: parseAttempts,
}); });
} }
// Generate public key from private key // Generate public key from private key
const publicKeyObj = crypto.createPublicKey(privateKeyObj); const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({ const publicKeyPem = publicKeyObj.export({
type: 'spki', type: "spki",
format: 'pem' format: "pem",
}); });
// Debug: Check what we're actually generating // Debug: Check what we're actually generating
console.log("Generated public key type:", typeof publicKeyPem); console.log("Generated public key type:", typeof publicKeyPem);
console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem)); console.log(
"Generated public key is Buffer:",
Buffer.isBuffer(publicKeyPem),
);
// Ensure publicKeyPem is a string // Ensure publicKeyPem is a string
const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8'); const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: publicKeyPem.toString("utf8");
console.log("Public key string length:", publicKeyString.length); console.log("Public key string length:", publicKeyString.length);
console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100)); console.log(
console.log("Public key is string:", typeof publicKeyString === 'string'); "Generated public key first 100 chars:",
console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----')); 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 // Detect key type from the private key object
let keyType = 'unknown'; let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType; const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === 'rsa') { if (asymmetricKeyType === "rsa") {
keyType = 'ssh-rsa'; keyType = "ssh-rsa";
} else if (asymmetricKeyType === 'ed25519') { } else if (asymmetricKeyType === "ed25519") {
keyType = 'ssh-ed25519'; keyType = "ssh-ed25519";
} else if (asymmetricKeyType === 'ec') { } else if (asymmetricKeyType === "ec") {
// For EC keys, we need to check the curve // For EC keys, we need to check the curve
keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256 keyType = "ecdsa-sha2-nistp256"; // Default assumption for P-256
} }
// Use ssh2 to generate SSH format public key // Use ssh2 to generate SSH format public key
let finalPublicKey = publicKeyString; // PEM fallback let finalPublicKey = publicKeyString; // PEM fallback
let formatType = 'pem'; let formatType = "pem";
try { try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword); const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) { if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH(); const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString('base64'); const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`; finalPublicKey = `${keyType} ${base64Data}`;
formatType = 'ssh'; formatType = "ssh";
console.log("SSH format public key generated!"); console.log("SSH format public key generated!");
} else { } else {
console.warn("ssh2 parsing failed, using PEM format"); console.warn("ssh2 parsing failed, using PEM format");
@@ -1098,13 +1189,19 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
success: true, success: true,
publicKey: finalPublicKey, publicKey: finalPublicKey,
keyType: keyType, keyType: keyType,
format: formatType format: formatType,
}; };
console.log("Final response publicKey type:", typeof response.publicKey); console.log("Final response publicKey type:", typeof response.publicKey);
console.log("Final response publicKey format:", response.format); console.log("Final response publicKey format:", response.format);
console.log("Final response publicKey length:", response.publicKey.length); console.log(
console.log("Public key generated successfully using crypto module:", keyType); "Final response publicKey length:",
response.publicKey.length,
);
console.log(
"Public key generated successfully using crypto module:",
keyType,
);
res.json(response); res.json(response);
} catch (error) { } catch (error) {
@@ -1112,16 +1209,20 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
authLogger.error("Failed to generate public key", error); authLogger.error("Failed to generate public key", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error instanceof Error ? error.message : "Failed to generate public key" error:
error instanceof Error
? error.message
: "Failed to generate public key",
}); });
} }
}); },
);
// SSH Key Deployment Function // SSH Key Deployment Function
async function deploySSHKeyToHost( async function deploySSHKeyToHost(
hostConfig: any, hostConfig: any,
publicKey: string, publicKey: string,
credentialData: any credentialData: any,
): Promise<{ success: boolean; message?: string; error?: string }> { ): Promise<{ success: boolean; message?: string; error?: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const conn = new Client(); const conn = new Client();
@@ -1133,16 +1234,16 @@ async function deploySSHKeyToHost(
resolve({ success: false, error: "Connection timeout" }); resolve({ success: false, error: "Connection timeout" });
}, 30000); }, 30000);
conn.on('ready', async () => { conn.on("ready", async () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
try { try {
// Step 1: Create ~/.ssh directory if it doesn't exist // Step 1: Create ~/.ssh directory if it doesn't exist
await new Promise<void>((resolveCmd, rejectCmd) => { await new Promise<void>((resolveCmd, rejectCmd) => {
conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => { conn.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh", (err, stream) => {
if (err) return rejectCmd(err); if (err) return rejectCmd(err);
stream.on('close', (code) => { stream.on("close", (code) => {
if (code === 0) { if (code === 0) {
resolveCmd(); resolveCmd();
} else { } else {
@@ -1153,16 +1254,21 @@ async function deploySSHKeyToHost(
}); });
// Step 2: Check if public key already exists // Step 2: Check if public key already exists
const keyExists = await new Promise<boolean>((resolveCheck, rejectCheck) => { const keyExists = await new Promise<boolean>(
const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm (resolveCheck, rejectCheck) => {
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => { 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); if (err) return rejectCheck(err);
stream.on('close', (code) => { stream.on("close", (code) => {
resolveCheck(code === 0); // code 0 means key found resolveCheck(code === 0); // code 0 means key found
}); });
}); },
}); );
},
);
if (keyExists) { if (keyExists) {
conn.end(); conn.end();
@@ -1173,48 +1279,61 @@ async function deploySSHKeyToHost(
// Step 3: Add public key to authorized_keys // Step 3: Add public key to authorized_keys
await new Promise<void>((resolveAdd, rejectAdd) => { await new Promise<void>((resolveAdd, rejectAdd) => {
const escapedKey = publicKey.replace(/'/g, "'\\''"); const escapedKey = publicKey.replace(/'/g, "'\\''");
conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { conn.exec(
`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) return rejectAdd(err); if (err) return rejectAdd(err);
stream.on('close', (code) => { stream.on("close", (code) => {
if (code === 0) { if (code === 0) {
resolveAdd(); resolveAdd();
} else { } else {
rejectAdd(new Error(`Key deployment failed with code ${code}`)); rejectAdd(
new Error(`Key deployment failed with code ${code}`),
);
} }
}); });
}); },
);
}); });
// Step 4: Verify deployment // Step 4: Verify deployment
const verifySuccess = await new Promise<boolean>((resolveVerify, rejectVerify) => { const verifySuccess = await new Promise<boolean>(
const keyPattern = publicKey.split(' ')[1]; (resolveVerify, rejectVerify) => {
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => { const keyPattern = publicKey.split(" ")[1];
conn.exec(
`grep -q "${keyPattern}" ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) return rejectVerify(err); if (err) return rejectVerify(err);
stream.on('close', (code) => { stream.on("close", (code) => {
resolveVerify(code === 0); resolveVerify(code === 0);
}); });
}); },
}); );
},
);
conn.end(); conn.end();
if (verifySuccess) { if (verifySuccess) {
resolve({ success: true, message: "SSH key deployed successfully" }); resolve({ success: true, message: "SSH key deployed successfully" });
} else { } else {
resolve({ success: false, error: "Key deployment verification failed" }); resolve({
success: false,
error: "Key deployment verification failed",
});
} }
} catch (error) { } catch (error) {
conn.end(); conn.end();
resolve({ resolve({
success: false, success: false,
error: error instanceof Error ? error.message : "Deployment failed" error: error instanceof Error ? error.message : "Deployment failed",
}); });
} }
}); });
conn.on('error', (err) => { conn.on("error", (err) => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
resolve({ success: false, error: err.message }); resolve({ success: false, error: err.message });
}); });
@@ -1227,15 +1346,18 @@ async function deploySSHKeyToHost(
username: hostConfig.username, username: hostConfig.username,
}; };
if (hostConfig.authType === 'password' && hostConfig.password) { if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password; connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === 'key' && hostConfig.privateKey) { } else if (hostConfig.authType === "key" && hostConfig.privateKey) {
connectionConfig.privateKey = hostConfig.privateKey; connectionConfig.privateKey = hostConfig.privateKey;
if (hostConfig.keyPassword) { if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword; connectionConfig.passphrase = hostConfig.keyPassword;
} }
} else { } else {
resolve({ success: false, error: "Invalid authentication configuration" }); resolve({
success: false,
error: "Invalid authentication configuration",
});
return; return;
} }
@@ -1244,7 +1366,7 @@ async function deploySSHKeyToHost(
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
resolve({ resolve({
success: false, success: false,
error: error instanceof Error ? error.message : "Connection failed" error: error instanceof Error ? error.message : "Connection failed",
}); });
} }
}); });
@@ -1252,15 +1374,17 @@ async function deploySSHKeyToHost(
// Deploy SSH Key to Host endpoint // Deploy SSH Key to Host endpoint
// POST /credentials/:id/deploy-to-host // POST /credentials/:id/deploy-to-host
router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => { router.post(
"/:id/deploy-to-host",
authenticateJWT,
async (req: Request, res: Response) => {
const credentialId = parseInt(req.params.id); const credentialId = parseInt(req.params.id);
const { targetHostId } = req.body; const { targetHostId } = req.body;
if (!credentialId || !targetHostId) { if (!credentialId || !targetHostId) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Credential ID and target host ID are required" error: "Credential ID and target host ID are required",
}); });
} }
@@ -1275,24 +1399,24 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
if (!credential || credential.length === 0) { if (!credential || credential.length === 0) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Credential not found" error: "Credential not found",
}); });
} }
const credData = credential[0]; const credData = credential[0];
// Only support key-based credentials for deployment // Only support key-based credentials for deployment
if (credData.authType !== 'key') { if (credData.authType !== "key") {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Only SSH key-based credentials can be deployed" error: "Only SSH key-based credentials can be deployed",
}); });
} }
if (!credData.publicKey) { if (!credData.publicKey) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Public key is required for deployment" error: "Public key is required for deployment",
}); });
} }
@@ -1306,7 +1430,7 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
if (!targetHost || targetHost.length === 0) { if (!targetHost || targetHost.length === 0) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Target host not found" error: "Target host not found",
}); });
} }
@@ -1320,11 +1444,11 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
authType: hostData.authType, authType: hostData.authType,
password: hostData.password, password: hostData.password,
privateKey: hostData.key, privateKey: hostData.key,
keyPassword: hostData.keyPassword keyPassword: hostData.keyPassword,
}; };
// If host uses credential authentication, resolve the credential // If host uses credential authentication, resolve the credential
if (hostData.authType === 'credential' && hostData.credentialId) { if (hostData.authType === "credential" && hostData.credentialId) {
const hostCredential = await db const hostCredential = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
@@ -1338,16 +1462,16 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
hostConfig.authType = cred.authType; hostConfig.authType = cred.authType;
hostConfig.username = cred.username; // Use credential's username hostConfig.username = cred.username; // Use credential's username
if (cred.authType === 'password') { if (cred.authType === "password") {
hostConfig.password = cred.password; hostConfig.password = cred.password;
} else if (cred.authType === 'key') { } else if (cred.authType === "key") {
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
hostConfig.keyPassword = cred.keyPassword; hostConfig.keyPassword = cred.keyPassword;
} }
} else { } else {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Host credential not found" error: "Host credential not found",
}); });
} }
} }
@@ -1356,7 +1480,7 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
const deployResult = await deploySSHKeyToHost( const deployResult = await deploySSHKeyToHost(
hostConfig, hostConfig,
credData.publicKey, credData.publicKey,
credData credData,
); );
if (deployResult.success) { if (deployResult.success) {
@@ -1364,33 +1488,35 @@ router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Re
authLogger.info(`SSH key deployed successfully`, { authLogger.info(`SSH key deployed successfully`, {
credentialId, credentialId,
targetHostId, targetHostId,
operation: "deploy_ssh_key" operation: "deploy_ssh_key",
}); });
res.json({ res.json({
success: true, success: true,
message: deployResult.message || "SSH key deployed successfully" message: deployResult.message || "SSH key deployed successfully",
}); });
} else { } else {
authLogger.error(`SSH key deployment failed`, { authLogger.error(`SSH key deployment failed`, {
credentialId, credentialId,
targetHostId, targetHostId,
error: deployResult.error, error: deployResult.error,
operation: "deploy_ssh_key" operation: "deploy_ssh_key",
}); });
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: deployResult.error || "Deployment failed" error: deployResult.error || "Deployment failed",
}); });
} }
} catch (error) { } catch (error) {
authLogger.error("Failed to deploy SSH key", error); authLogger.error("Failed to deploy SSH key", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error instanceof Error ? error.message : "Failed to deploy SSH key" error:
error instanceof Error ? error.message : "Failed to deploy SSH key",
}); });
} }
}); },
);
export default router; export default router;

View File

@@ -65,7 +65,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
try { try {
const data = await EncryptedDBOperations.select( const data = await EncryptedDBOperations.select(
db.select().from(sshData), db.select().from(sshData),
'ssh_data' "ssh_data",
); );
const result = data.map((row: any) => { const result = data.map((row: any) => {
return { return {
@@ -210,7 +210,11 @@ router.post(
} }
try { try {
const result = await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj); const result = await EncryptedDBOperations.insert(
sshData,
"ssh_data",
sshDataObj,
);
if (!result) { if (!result) {
sshLogger.warn("No host returned after creation", { sshLogger.warn("No host returned after creation", {
@@ -403,14 +407,19 @@ router.put(
try { try {
await EncryptedDBOperations.update( await EncryptedDBOperations.update(
sshData, sshData,
'ssh_data', "ssh_data",
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)), and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
sshDataObj sshDataObj,
); );
const updatedHosts = await EncryptedDBOperations.select( const updatedHosts = await EncryptedDBOperations.select(
db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))), db
'ssh_data' .select()
.from(sshData)
.where(
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
),
"ssh_data",
); );
if (updatedHosts.length === 0) { if (updatedHosts.length === 0) {
@@ -486,7 +495,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
try { try {
const data = await EncryptedDBOperations.select( const data = await EncryptedDBOperations.select(
db.select().from(sshData).where(eq(sshData.userId, userId)), db.select().from(sshData).where(eq(sshData.userId, userId)),
'ssh_data' "ssh_data",
); );
const result = await Promise.all( const result = await Promise.all(
@@ -1106,12 +1115,12 @@ router.put(
try { try {
const updatedHosts = await EncryptedDBOperations.update( const updatedHosts = await EncryptedDBOperations.update(
sshData, sshData,
'ssh_data', "ssh_data",
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)), and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
{ {
folder: newName, folder: newName,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} },
); );
const updatedCredentials = await db const updatedCredentials = await db
@@ -1252,7 +1261,7 @@ router.post(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj); await EncryptedDBOperations.insert(sshData, "ssh_data", sshDataObj);
results.success++; results.success++;
} catch (error) { } catch (error) {
results.failed++; results.failed++;

View File

@@ -10,20 +10,38 @@ import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
// 可执行文件检测工具函数 // 可执行文件检测工具函数
function isExecutableFile(permissions: string, fileName: string): boolean { function isExecutableFile(permissions: string, fileName: string): boolean {
// 检查执行权限位 (user, group, other) // 检查执行权限位 (user, group, other)
const hasExecutePermission = permissions[3] === 'x' || permissions[6] === 'x' || permissions[9] === 'x'; const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
// 常见的脚本文件扩展名 // 常见的脚本文件扩展名
const scriptExtensions = ['.sh', '.py', '.pl', '.rb', '.js', '.php', '.bash', '.zsh', '.fish']; const scriptExtensions = [
const hasScriptExtension = scriptExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); ".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
// 常见的编译可执行文件(无扩展名或特定扩展名) // 常见的编译可执行文件(无扩展名或特定扩展名)
const executableExtensions = ['.bin', '.exe', '.out']; const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
// 无扩展名且有执行权限的文件通常是可执行文件 // 无扩展名且有执行权限的文件通常是可执行文件
const hasNoExtension = !fileName.includes('.') && hasExecutePermission; const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return hasExecutePermission && (hasScriptExtension || hasExecutableExtension || hasNoExtension); return (
hasExecutePermission &&
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
);
} }
const app = express(); const app = express();
@@ -106,13 +124,16 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (credentialId && hostId && userId) { if (credentialId && hostId && userId) {
try { try {
const credentials = await EncryptedDBOperations.select( const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where( db
.select()
.from(sshCredentials)
.where(
and( and(
eq(sshCredentials.id, credentialId), eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
), ),
), ),
'ssh_credentials' "ssh_credentials",
); );
if (credentials.length > 0) { if (credentials.length > 0) {
@@ -140,12 +161,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}); });
} }
} else if (credentialId && hostId) { } else if (credentialId && hostId) {
fileLogger.warn("Missing userId for credential resolution in file manager", { fileLogger.warn(
"Missing userId for credential resolution in file manager",
{
operation: "ssh_credentials", operation: "ssh_credentials",
hostId, hostId,
credentialId, credentialId,
hasUserId: !!userId, hasUserId: !!userId,
}); },
);
} }
const config: any = { const config: any = {
@@ -360,8 +384,11 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
owner, owner,
group, group,
linkTarget, // 符号链接的目标 linkTarget, // 符号链接的目标
path: `${sshPath.endsWith('/') ? sshPath : sshPath + '/'}${actualName}`, // 添加完整路径 path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // 添加完整路径
executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false // 检测可执行文件 executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false, // 检测可执行文件
}); });
} }
} }
@@ -423,7 +450,9 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
res.json({ res.json({
path: linkPath, path: linkPath,
target: target, target: target,
type: fileType.toLowerCase().includes("directory") ? "directory" : "file" type: fileType.toLowerCase().includes("directory")
? "directory"
: "file",
}); });
}); });
@@ -460,7 +489,9 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
// Get file size first // Get file size first
sshConn.client.exec(`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, (sizeErr, sizeStream) => { sshConn.client.exec(
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
(sizeErr, sizeStream) => {
if (sizeErr) { if (sizeErr) {
fileLogger.error("SSH file size check error:", sizeErr); fileLogger.error("SSH file size check error:", sizeErr);
return res.status(500).json({ error: sizeErr.message }); return res.status(500).json({ error: sizeErr.message });
@@ -480,7 +511,9 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
sizeStream.on("close", (sizeCode) => { sizeStream.on("close", (sizeCode) => {
if (sizeCode !== 0) { if (sizeCode !== 0) {
fileLogger.error(`File size check failed: ${sizeErrorData}`); fileLogger.error(`File size check failed: ${sizeErrorData}`);
return res.status(500).json({ error: `Cannot check file size: ${sizeErrorData}` }); return res
.status(500)
.json({ error: `Cannot check file size: ${sizeErrorData}` });
} }
const fileSize = parseInt(sizeData.trim(), 10); const fileSize = parseInt(sizeData.trim(), 10);
@@ -503,7 +536,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
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.`, 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, fileSize,
maxSize: MAX_READ_SIZE, maxSize: MAX_READ_SIZE,
tooLarge: true tooLarge: true,
}); });
} }
@@ -530,14 +563,17 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
fileLogger.error( fileLogger.error(
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
); );
return res.status(500).json({ error: `Command failed: ${errorData}` }); return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
} }
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) => {
@@ -1542,12 +1578,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
}); });
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { const { sessionId, path: filePath, hostId, userId } = req.body;
sessionId,
path: filePath,
hostId,
userId,
} = req.body;
if (!sessionId || !filePath) { if (!sessionId || !filePath) {
fileLogger.warn("Missing download parameters", { fileLogger.warn("Missing download parameters", {
@@ -1565,7 +1596,9 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
sessionId, sessionId,
isConnected: sshConn?.isConnected, isConnected: sshConn?.isConnected,
}); });
return res.status(400).json({ error: "SSH session not found or not connected" }); return res
.status(400)
.json({ error: "SSH session not found or not connected" });
} }
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
@@ -1582,7 +1615,9 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
sftp.stat(filePath, (statErr, stats) => { sftp.stat(filePath, (statErr, stats) => {
if (statErr) { if (statErr) {
fileLogger.error("File stat failed for download:", statErr); fileLogger.error("File stat failed for download:", statErr);
return res.status(500).json({ error: `Cannot access file: ${statErr.message}` }); return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
} }
if (!stats.isFile()) { if (!stats.isFile()) {
@@ -1593,7 +1628,9 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
isFile: stats.isFile(), isFile: stats.isFile(),
isDirectory: stats.isDirectory(), isDirectory: stats.isDirectory(),
}); });
return res.status(400).json({ error: "Cannot download directories or special files" }); return res
.status(400)
.json({ error: "Cannot download directories or special files" });
} }
// Check file size (limit to 100MB for safety) // Check file size (limit to 100MB for safety)
@@ -1607,7 +1644,7 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
maxSize: MAX_FILE_SIZE, maxSize: MAX_FILE_SIZE,
}); });
return res.status(400).json({ 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` error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
}); });
} }
@@ -1615,12 +1652,14 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
sftp.readFile(filePath, (readErr, data) => { sftp.readFile(filePath, (readErr, data) => {
if (readErr) { if (readErr) {
fileLogger.error("File read failed for download:", readErr); fileLogger.error("File read failed for download:", readErr);
return res.status(500).json({ error: `Failed to read file: ${readErr.message}` }); return res
.status(500)
.json({ error: `Failed to read file: ${readErr.message}` });
} }
// Convert to base64 for safe transport // Convert to base64 for safe transport
const base64Content = data.toString('base64'); const base64Content = data.toString("base64");
const fileName = filePath.split('/').pop() || 'download'; const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File downloaded successfully", { fileLogger.success("File downloaded successfully", {
operation: "file_download", operation: "file_download",
@@ -1654,7 +1693,9 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) { if (!sshConn || !sshConn.isConnected) {
return res.status(400).json({ error: "SSH session not found or not connected" }); return res
.status(400)
.json({ error: "SSH session not found or not connected" });
} }
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
@@ -1662,7 +1703,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
try { try {
// Extract source name // Extract source name
const sourceName = sourcePath.split('/').pop() || 'copied_item'; const sourceName = sourcePath.split("/").pop() || "copied_item";
// First check if source file exists // First check if source file exists
const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'"); const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'");
@@ -1676,7 +1717,10 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
} }
stream.on("close", (code) => { stream.on("close", (code) => {
fileLogger.info("File existence check completed", { sourcePath, exists: code === 0 }); fileLogger.info("File existence check completed", {
sourcePath,
exists: code === 0,
});
resolve(code === 0); resolve(code === 0);
}); });
@@ -1687,23 +1731,29 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
if (!checkExists) { if (!checkExists) {
return res.status(404).json({ return res.status(404).json({
error: `Source file not found: ${sourcePath}`, error: `Source file not found: ${sourcePath}`,
toast: { type: "error", message: `Source file not found: ${sourceName}` } toast: {
type: "error",
message: `Source file not found: ${sourceName}`,
},
}); });
} }
// Use timestamp for uniqueness // Use timestamp for uniqueness
const timestamp = Date.now().toString().slice(-8); const timestamp = Date.now().toString().slice(-8);
const nameWithoutExt = sourceName.includes('.') const nameWithoutExt = sourceName.includes(".")
? sourceName.substring(0, sourceName.lastIndexOf('.')) ? sourceName.substring(0, sourceName.lastIndexOf("."))
: sourceName; : sourceName;
const extension = sourceName.includes('.') const extension = sourceName.includes(".")
? sourceName.substring(sourceName.lastIndexOf('.')) ? sourceName.substring(sourceName.lastIndexOf("."))
: ''; : "";
// Always use timestamp suffix to ensure uniqueness without SSH calls // Always use timestamp suffix to ensure uniqueness without SSH calls
const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`; const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`;
fileLogger.info("Using timestamp-based unique name", { originalName: sourceName, uniqueName }); fileLogger.info("Using timestamp-based unique name", {
originalName: sourceName,
uniqueName,
});
const targetPath = `${targetDir}/${uniqueName}`; const targetPath = `${targetDir}/${uniqueName}`;
// Escape paths for shell commands // Escape paths for shell commands
@@ -1722,7 +1772,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
sourcePath, sourcePath,
targetPath, targetPath,
uniqueName, uniqueName,
command: copyCommand.substring(0, 200) + "..." // Log truncated command command: copyCommand.substring(0, 200) + "...", // Log truncated command
}); });
// Add timeout to prevent hanging // Add timeout to prevent hanging
@@ -1730,12 +1780,16 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
fileLogger.error("Copy command timed out after 20 seconds", { fileLogger.error("Copy command timed out after 20 seconds", {
sourcePath, sourcePath,
targetPath, targetPath,
command: copyCommand command: copyCommand,
}); });
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Copy operation timed out", error: "Copy operation timed out",
toast: { type: "error", message: "Copy operation timed out. SSH connection may be unstable." } toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
}); });
} }
}, 20000); // 20 second timeout for better responsiveness }, 20000); // 20 second timeout for better responsiveness
@@ -1757,21 +1811,30 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {
const output = data.toString(); const output = data.toString();
stdoutData += output; stdoutData += output;
fileLogger.info("Copy command stdout", { output: output.substring(0, 200) }); fileLogger.info("Copy command stdout", {
output: output.substring(0, 200),
});
}); });
stream.stderr.on("data", (data: Buffer) => { stream.stderr.on("data", (data: Buffer) => {
const output = data.toString(); const output = data.toString();
errorData += output; errorData += output;
fileLogger.info("Copy command stderr", { output: output.substring(0, 200) }); fileLogger.info("Copy command stderr", {
output: output.substring(0, 200),
});
}); });
stream.on("close", (code) => { stream.on("close", (code) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
fileLogger.info("Copy command completed", { code, errorData, hasError: errorData.length > 0 }); fileLogger.info("Copy command completed", {
code,
errorData,
hasError: errorData.length > 0,
});
if (code !== 0) { if (code !== 0) {
const fullErrorInfo = errorData || stdoutData || 'No error message available'; const fullErrorInfo =
errorData || stdoutData || "No error message available";
fileLogger.error(`SSH copyItem command failed with code ${code}`, { fileLogger.error(`SSH copyItem command failed with code ${code}`, {
operation: "file_copy_failed", operation: "file_copy_failed",
sessionId, sessionId,
@@ -1781,18 +1844,21 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
exitCode: code, exitCode: code,
errorData, errorData,
stdoutData, stdoutData,
fullErrorInfo fullErrorInfo,
}); });
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({ return res.status(500).json({
error: `Copy failed: ${fullErrorInfo}`, error: `Copy failed: ${fullErrorInfo}`,
toast: { type: "error", message: `Copy failed: ${fullErrorInfo}` }, toast: {
type: "error",
message: `Copy failed: ${fullErrorInfo}`,
},
debug: { debug: {
sourcePath, sourcePath,
targetPath, targetPath,
exitCode: code, exitCode: code,
command: copyCommand command: copyCommand,
} },
}); });
} }
return; return;
@@ -1830,7 +1896,6 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
} }
}); });
}); });
} catch (error: any) { } catch (error: any) {
fileLogger.error("Copy operation error:", error); fileLogger.error("Copy operation error:", error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1839,23 +1904,23 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
// Helper function to determine MIME type based on file extension // Helper function to determine MIME type based on file extension
function getMimeType(fileName: string): string { function getMimeType(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
'txt': 'text/plain', txt: "text/plain",
'json': 'application/json', json: "application/json",
'js': 'text/javascript', js: "text/javascript",
'html': 'text/html', html: "text/html",
'css': 'text/css', css: "text/css",
'png': 'image/png', png: "image/png",
'jpg': 'image/jpeg', jpg: "image/jpeg",
'jpeg': 'image/jpeg', jpeg: "image/jpeg",
'gif': 'image/gif', gif: "image/gif",
'pdf': 'application/pdf', pdf: "application/pdf",
'zip': 'application/zip', zip: "application/zip",
'tar': 'application/x-tar', tar: "application/x-tar",
'gz': 'application/gzip', gz: "application/gzip",
}; };
return mimeTypes[ext || ''] || 'application/octet-stream'; return mimeTypes[ext || ""] || "application/octet-stream";
} }
process.on("SIGINT", () => { process.on("SIGINT", () => {
@@ -1874,12 +1939,15 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) { if (!sshConn || !sshConn.isConnected) {
fileLogger.error("SSH connection not found or not connected for executeFile", { fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file", operation: "execute_file",
sessionId, sessionId,
hasConnection: !!sshConn, hasConnection: !!sshConn,
isConnected: sshConn?.isConnected isConnected: sshConn?.isConnected,
}); },
);
return res.status(400).json({ error: "SSH connection not available" }); return res.status(400).json({ error: "SSH connection not available" });
} }
@@ -1895,10 +1963,12 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
sshConn.client.exec(checkCommand, (checkErr, checkStream) => { sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) { if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr); fileLogger.error("SSH executeFile check error:", checkErr);
return res.status(500).json({ error: "Failed to check file executability" }); return res
.status(500)
.json({ error: "Failed to check file executability" });
} }
let checkResult = ''; let checkResult = "";
checkStream.on("data", (data) => { checkStream.on("data", (data) => {
checkResult += data.toString(); checkResult += data.toString();
}); });
@@ -1915,7 +1985,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
operation: "execute_file", operation: "execute_file",
sessionId, sessionId,
filePath, filePath,
command: executeCommand.substring(0, 100) + "..." command: executeCommand.substring(0, 100) + "...",
}); });
sshConn.client.exec(executeCommand, (err, stream) => { sshConn.client.exec(executeCommand, (err, stream) => {
@@ -1924,8 +1994,8 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
return res.status(500).json({ error: "Failed to execute file" }); return res.status(500).json({ error: "Failed to execute file" });
} }
let output = ''; let output = "";
let errorOutput = ''; let errorOutput = "";
stream.on("data", (data) => { stream.on("data", (data) => {
output += data.toString(); output += data.toString();
@@ -1938,8 +2008,10 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
stream.on("close", (code) => { stream.on("close", (code) => {
// 从输出中提取退出代码 // 从输出中提取退出代码
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code; const actualExitCode = exitCodeMatch
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, '').trim(); ? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", { fileLogger.info("File execution completed", {
operation: "execute_file", operation: "execute_file",
@@ -1947,7 +2019,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
filePath, filePath,
exitCode: actualExitCode, exitCode: actualExitCode,
outputLength: cleanOutput.length, outputLength: cleanOutput.length,
errorLength: errorOutput.length errorLength: errorOutput.length,
}); });
res.json({ res.json({
@@ -1955,7 +2027,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
exitCode: actualExitCode, exitCode: actualExitCode,
output: cleanOutput, output: cleanOutput,
error: errorOutput, error: errorOutput,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
}); });

View File

@@ -309,7 +309,7 @@ async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
try { try {
const hosts = await EncryptedDBOperations.select( const hosts = await EncryptedDBOperations.select(
db.select().from(sshData), db.select().from(sshData),
'ssh_data' "ssh_data",
); );
const hostsWithCredentials: SSHHostWithCredentials[] = []; const hostsWithCredentials: SSHHostWithCredentials[] = [];
@@ -339,7 +339,7 @@ async function fetchHostById(
try { try {
const hosts = await EncryptedDBOperations.select( const hosts = await EncryptedDBOperations.select(
db.select().from(sshData).where(eq(sshData.id, id)), db.select().from(sshData).where(eq(sshData.id, id)),
'ssh_data' "ssh_data",
); );
if (hosts.length === 0) { if (hosts.length === 0) {
@@ -358,17 +358,6 @@ 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,
@@ -399,24 +388,32 @@ async function resolveHostCredentials(
if (host.credentialId) { if (host.credentialId) {
try { try {
const credentials = await EncryptedDBOperations.select( const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(and( db
.select()
.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' ),
"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}`, { statsLogger.debug(
operation: 'credential_resolve', `Using credential ${credential.id} for host ${host.id}`,
{
operation: "credential_resolve",
credentialId: credential.id, credentialId: credential.id,
authType: credential.authType, authType: credential.authType,
hasPassword: !!credential.password, hasPassword: !!credential.password,
hasKey: !!credential.key, hasKey: !!credential.key,
passwordLength: credential.password?.length || 0, passwordLength: credential.password?.length || 0,
keyLength: credential.key?.length || 0 keyLength: credential.key?.length || 0,
}); },
);
baseHost.credentialId = credential.id; baseHost.credentialId = credential.id;
baseHost.username = credential.username; baseHost.username = credential.username;
@@ -435,9 +432,6 @@ async function resolveHostCredentials(
baseHost.keyType = credential.keyType; baseHost.keyType = credential.keyType;
} }
} else { } else {
statsLogger.warn(
`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`,
);
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
} }
} catch (error) { } catch (error) {
@@ -447,25 +441,9 @@ 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(
@@ -484,7 +462,7 @@ 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}`, { statsLogger.debug(`Building SSH config for host ${host.ip}`, {
operation: 'ssh_config', operation: "ssh_config",
authType: host.authType, authType: host.authType,
hasPassword: !!host.password, hasPassword: !!host.password,
hasKey: !!host.key, hasKey: !!host.key,
@@ -492,7 +470,9 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
passwordLength: host.password?.length || 0, passwordLength: host.password?.length || 0,
keyLength: host.key?.length || 0, keyLength: host.key?.length || 0,
passwordType: typeof host.password, passwordType: typeof host.password,
passwordRaw: host.password ? JSON.stringify(host.password.substring(0, 20)) : null passwordRaw: host.password
? JSON.stringify(host.password.substring(0, 20))
: null,
}); });
const base: ConnectConfig = { const base: ConnectConfig = {
@@ -508,12 +488,12 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
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}`, { statsLogger.debug(`Using password auth for ${host.ip}`, {
operation: 'ssh_config', operation: "ssh_config",
passwordLength: host.password.length, passwordLength: host.password.length,
passwordFirst3: host.password.substring(0, 3), passwordFirst3: host.password.substring(0, 3),
passwordLast3: host.password.substring(host.password.length - 3), passwordLast3: host.password.substring(host.password.length - 3),
passwordType: typeof host.password, passwordType: typeof host.password,
passwordIsString: typeof host.password === 'string' 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") {
@@ -522,9 +502,9 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
} }
statsLogger.debug(`Using key auth for ${host.ip}`, { statsLogger.debug(`Using key auth for ${host.ip}`, {
operation: 'ssh_config', operation: "ssh_config",
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + '...', keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + "...",
hasPassphrase: !!host.keyPassword hasPassphrase: !!host.keyPassword,
}); });
try { try {

View File

@@ -178,22 +178,22 @@ wss.on("connection", (ws: WebSocket) => {
}, 60000); }, 60000);
sshLogger.debug(`Terminal SSH setup`, { sshLogger.debug(`Terminal SSH setup`, {
operation: 'terminal_ssh', operation: "terminal_ssh",
hostId: id, hostId: id,
ip, ip,
authType, authType,
hasPassword: !!password, hasPassword: !!password,
passwordLength: password?.length || 0, passwordLength: password?.length || 0,
hasCredentialId: !!credentialId hasCredentialId: !!credentialId,
}); });
if (password) { if (password) {
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, { sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
operation: 'terminal_ssh_password' operation: "terminal_ssh_password",
}); });
} else { } else {
sshLogger.debug(`No password provided`, { sshLogger.debug(`No password provided`, {
operation: 'terminal_ssh_password' operation: "terminal_ssh_password",
}); });
} }
@@ -201,13 +201,16 @@ wss.on("connection", (ws: WebSocket) => {
if (credentialId && id && hostConfig.userId) { if (credentialId && id && hostConfig.userId) {
try { try {
const credentials = await EncryptedDBOperations.select( const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where( db
.select()
.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' "ssh_credentials",
); );
if (credentials.length > 0) { if (credentials.length > 0) {

View File

@@ -32,7 +32,14 @@ import "dotenv/config";
systemLogger.success("All backend services initialized successfully", { systemLogger.success("All backend services initialized successfully", {
operation: "startup_complete", operation: "startup_complete",
services: ["database", "encryption", "terminal", "tunnel", "file_manager", "stats"], services: [
"database",
"encryption",
"terminal",
"tunnel",
"file_manager",
"stats",
],
version: version, version: version,
}); });

View File

@@ -1,6 +1,6 @@
import { FieldEncryption } from './encryption.js'; import { FieldEncryption } from "./encryption.js";
import { EncryptionKeyManager } from './encryption-key-manager.js'; import { EncryptionKeyManager } from "./encryption-key-manager.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
interface EncryptionContext { interface EncryptionContext {
masterPassword: string; masterPassword: string;
@@ -14,26 +14,29 @@ class DatabaseEncryption {
static async initialize(config: Partial<EncryptionContext> = {}) { static async initialize(config: Partial<EncryptionContext> = {}) {
const keyManager = EncryptionKeyManager.getInstance(); const keyManager = EncryptionKeyManager.getInstance();
const masterPassword = config.masterPassword || await keyManager.initializeKey(); const masterPassword =
config.masterPassword || (await keyManager.initializeKey());
this.context = { this.context = {
masterPassword, masterPassword,
encryptionEnabled: config.encryptionEnabled ?? true, encryptionEnabled: config.encryptionEnabled ?? true,
forceEncryption: config.forceEncryption ?? false, forceEncryption: config.forceEncryption ?? false,
migrateOnAccess: config.migrateOnAccess ?? true migrateOnAccess: config.migrateOnAccess ?? true,
}; };
databaseLogger.info('Database encryption initialized', { databaseLogger.info("Database encryption initialized", {
operation: 'encryption_init', operation: "encryption_init",
enabled: this.context.encryptionEnabled, enabled: this.context.encryptionEnabled,
forceEncryption: this.context.forceEncryption, forceEncryption: this.context.forceEncryption,
dynamicKey: !config.masterPassword dynamicKey: !config.masterPassword,
}); });
} }
static getContext(): EncryptionContext { static getContext(): EncryptionContext {
if (!this.context) { if (!this.context) {
throw new Error('DatabaseEncryption not initialized. Call initialize() first.'); throw new Error(
"DatabaseEncryption not initialized. Call initialize() first.",
);
} }
return this.context; return this.context;
} }
@@ -48,15 +51,25 @@ class DatabaseEncryption {
for (const [fieldName, value] of Object.entries(record)) { for (const [fieldName, value] of Object.entries(record)) {
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) { if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
try { try {
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); const fieldKey = FieldEncryption.getFieldKey(
encryptedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey); context.masterPassword,
`${tableName}.${fieldName}`,
);
encryptedRecord[fieldName] = FieldEncryption.encryptField(
value as string,
fieldKey,
);
hasEncryption = true; hasEncryption = true;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to encrypt field ${tableName}.${fieldName}`, error, { databaseLogger.error(
operation: 'field_encryption', `Failed to encrypt field ${tableName}.${fieldName}`,
error,
{
operation: "field_encryption",
table: tableName, table: tableName,
field: fieldName field: fieldName,
}); },
);
throw error; throw error;
} }
} }
@@ -64,8 +77,8 @@ class DatabaseEncryption {
if (hasEncryption) { if (hasEncryption) {
databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, { databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, {
operation: 'record_encryption', operation: "record_encryption",
table: tableName table: tableName,
}); });
} }
@@ -83,28 +96,41 @@ class DatabaseEncryption {
for (const [fieldName, value] of Object.entries(record)) { for (const [fieldName, value] of Object.entries(record)) {
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) { if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
try { try {
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); const fieldKey = FieldEncryption.getFieldKey(
context.masterPassword,
`${tableName}.${fieldName}`,
);
if (FieldEncryption.isEncrypted(value as string)) { if (FieldEncryption.isEncrypted(value as string)) {
decryptedRecord[fieldName] = FieldEncryption.decryptField(value as string, fieldKey); decryptedRecord[fieldName] = FieldEncryption.decryptField(
value as string,
fieldKey,
);
hasDecryption = true; hasDecryption = true;
} else if (context.encryptionEnabled && !context.forceEncryption) { } else if (context.encryptionEnabled && !context.forceEncryption) {
decryptedRecord[fieldName] = value; decryptedRecord[fieldName] = value;
needsMigration = context.migrateOnAccess; needsMigration = context.migrateOnAccess;
} else if (context.forceEncryption) { } else if (context.forceEncryption) {
databaseLogger.warn(`Unencrypted field detected in force encryption mode`, { databaseLogger.warn(
operation: 'decryption_warning', `Unencrypted field detected in force encryption mode`,
{
operation: "decryption_warning",
table: tableName, table: tableName,
field: fieldName field: fieldName,
}); },
);
decryptedRecord[fieldName] = value; decryptedRecord[fieldName] = value;
} }
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to decrypt field ${tableName}.${fieldName}`, error, { databaseLogger.error(
operation: 'field_decryption', `Failed to decrypt field ${tableName}.${fieldName}`,
error,
{
operation: "field_decryption",
table: tableName, table: tableName,
field: fieldName field: fieldName,
}); },
);
if (context.forceEncryption) { if (context.forceEncryption) {
throw error; throw error;
@@ -115,13 +141,6 @@ class DatabaseEncryption {
} }
} }
if (hasDecryption) {
databaseLogger.debug(`Decrypted sensitive fields for ${tableName}`, {
operation: 'record_decryption',
table: tableName
});
}
if (needsMigration) { if (needsMigration) {
this.scheduleFieldMigration(tableName, record); this.scheduleFieldMigration(tableName, record);
} }
@@ -131,7 +150,7 @@ class DatabaseEncryption {
static decryptRecords(tableName: string, records: any[]): any[] { static decryptRecords(tableName: string, records: any[]): any[] {
if (!Array.isArray(records)) return records; if (!Array.isArray(records)) return records;
return records.map(record => this.decryptRecord(tableName, record)); return records.map((record) => this.decryptRecord(tableName, record));
} }
private static scheduleFieldMigration(tableName: string, record: any) { private static scheduleFieldMigration(tableName: string, record: any) {
@@ -139,11 +158,15 @@ class DatabaseEncryption {
try { try {
await this.migrateRecord(tableName, record); await this.migrateRecord(tableName, record);
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to migrate record ${tableName}:${record.id}`, error, { databaseLogger.error(
operation: 'migration_failed', `Failed to migrate record ${tableName}:${record.id}`,
error,
{
operation: "migration_failed",
table: tableName, table: tableName,
recordId: record.id recordId: record.id,
}); },
);
} }
}, 1000); }, 1000);
} }
@@ -156,49 +179,61 @@ class DatabaseEncryption {
const updatedRecord = { ...record }; const updatedRecord = { ...record };
for (const [fieldName, value] of Object.entries(record)) { for (const [fieldName, value] of Object.entries(record)) {
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && if (
value && !FieldEncryption.isEncrypted(value as string)) { FieldEncryption.shouldEncryptField(tableName, fieldName) &&
value &&
!FieldEncryption.isEncrypted(value as string)
) {
try { try {
const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); const fieldKey = FieldEncryption.getFieldKey(
updatedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey); context.masterPassword,
`${tableName}.${fieldName}`,
);
updatedRecord[fieldName] = FieldEncryption.encryptField(
value as string,
fieldKey,
);
needsUpdate = true; needsUpdate = true;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to migrate field ${tableName}.${fieldName}`, error, { databaseLogger.error(
operation: 'field_migration', `Failed to migrate field ${tableName}.${fieldName}`,
error,
{
operation: "field_migration",
table: tableName, table: tableName,
field: fieldName, field: fieldName,
recordId: record.id recordId: record.id,
}); },
);
throw error; throw error;
} }
} }
} }
if (needsUpdate) {
databaseLogger.info(`Migrated record to encrypted format`, {
operation: 'record_migration',
table: tableName,
recordId: record.id
});
}
return updatedRecord; return updatedRecord;
} }
static validateConfiguration(): boolean { static validateConfiguration(): boolean {
try { try {
const context = this.getContext(); const context = this.getContext();
const testData = 'test-encryption-data'; const testData = "test-encryption-data";
const testKey = FieldEncryption.getFieldKey(context.masterPassword, 'test'); const testKey = FieldEncryption.getFieldKey(
context.masterPassword,
"test",
);
const encrypted = FieldEncryption.encryptField(testData, testKey); const encrypted = FieldEncryption.encryptField(testData, testKey);
const decrypted = FieldEncryption.decryptField(encrypted, testKey); const decrypted = FieldEncryption.decryptField(encrypted, testKey);
return decrypted === testData; return decrypted === testData;
} catch (error) { } catch (error) {
databaseLogger.error('Encryption configuration validation failed', error, { databaseLogger.error(
operation: 'config_validation' "Encryption configuration validation failed",
}); error,
{
operation: "config_validation",
},
);
return false; return false;
} }
} }
@@ -210,14 +245,14 @@ class DatabaseEncryption {
enabled: context.encryptionEnabled, enabled: context.encryptionEnabled,
forceEncryption: context.forceEncryption, forceEncryption: context.forceEncryption,
migrateOnAccess: context.migrateOnAccess, migrateOnAccess: context.migrateOnAccess,
configValid: this.validateConfiguration() configValid: this.validateConfiguration(),
}; };
} catch { } catch {
return { return {
enabled: false, enabled: false,
forceEncryption: false, forceEncryption: false,
migrateOnAccess: false, migrateOnAccess: false,
configValid: false configValid: false,
}; };
} }
} }
@@ -230,7 +265,7 @@ class DatabaseEncryption {
return { return {
...encryptionStatus, ...encryptionStatus,
key: keyStatus, key: keyStatus,
initialized: this.context !== null initialized: this.context !== null,
}; };
} }
@@ -241,9 +276,9 @@ class DatabaseEncryption {
this.context = null; this.context = null;
await this.initialize({ masterPassword: newKey }); await this.initialize({ masterPassword: newKey });
databaseLogger.warn('Database encryption reinitialized with new key', { databaseLogger.warn("Database encryption reinitialized with new key", {
operation: 'encryption_reinit', operation: "encryption_reinit",
requiresMigration: true requiresMigration: true,
}); });
} }
} }

View File

@@ -1,8 +1,8 @@
import crypto from 'crypto'; import crypto from "crypto";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { HardwareFingerprint } from './hardware-fingerprint.js'; import { HardwareFingerprint } from "./hardware-fingerprint.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
interface EncryptedFileMetadata { interface EncryptedFileMetadata {
iv: string; iv: string;
@@ -18,11 +18,11 @@ interface EncryptedFileMetadata {
* This provides an additional security layer on top of field-level encryption * This provides an additional security layer on top of field-level encryption
*/ */
class DatabaseFileEncryption { class DatabaseFileEncryption {
private static readonly VERSION = 'v1'; private static readonly VERSION = "v1";
private static readonly ALGORITHM = 'aes-256-gcm'; private static readonly ALGORITHM = "aes-256-gcm";
private static readonly KEY_ITERATIONS = 100000; private static readonly KEY_ITERATIONS = 100000;
private static readonly ENCRYPTED_FILE_SUFFIX = '.encrypted'; private static readonly ENCRYPTED_FILE_SUFFIX = ".encrypted";
private static readonly METADATA_FILE_SUFFIX = '.meta'; private static readonly METADATA_FILE_SUFFIX = ".meta";
/** /**
* Generate file encryption key from hardware fingerprint * Generate file encryption key from hardware fingerprint
@@ -35,15 +35,9 @@ class DatabaseFileEncryption {
salt, salt,
this.KEY_ITERATIONS, this.KEY_ITERATIONS,
32, // 256 bits for AES-256 32, // 256 bits for AES-256
'sha256' "sha256",
); );
databaseLogger.debug('Generated file encryption key from hardware fingerprint', {
operation: 'file_key_generation',
iterations: this.KEY_ITERATIONS,
keyLength: key.length
});
return key; return key;
} }
@@ -59,20 +53,17 @@ class DatabaseFileEncryption {
// Encrypt the buffer // Encrypt the buffer
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any; const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
cipher.update(buffer),
cipher.final()
]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
// Create metadata // Create metadata
const metadata: EncryptedFileMetadata = { const metadata: EncryptedFileMetadata = {
iv: iv.toString('hex'), iv: iv.toString("hex"),
tag: tag.toString('hex'), tag: tag.toString("hex"),
version: this.VERSION, version: this.VERSION,
fingerprint: HardwareFingerprint.generate().substring(0, 16), fingerprint: HardwareFingerprint.generate().substring(0, 16),
salt: salt.toString('hex'), salt: salt.toString("hex"),
algorithm: this.ALGORITHM algorithm: this.ALGORITHM,
}; };
// Write encrypted file and metadata // Write encrypted file and metadata
@@ -80,21 +71,15 @@ class DatabaseFileEncryption {
fs.writeFileSync(targetPath, encrypted); fs.writeFileSync(targetPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); 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; return targetPath;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to encrypt database buffer', error, { databaseLogger.error("Failed to encrypt database buffer", error, {
operation: 'database_buffer_encryption_failed', operation: "database_buffer_encryption_failed",
targetPath targetPath,
}); });
throw new Error(`Database buffer encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -106,7 +91,8 @@ class DatabaseFileEncryption {
throw new Error(`Source database file does not exist: ${sourcePath}`); throw new Error(`Source database file does not exist: ${sourcePath}`);
} }
const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const encryptedPath =
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
try { try {
@@ -122,41 +108,43 @@ class DatabaseFileEncryption {
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any; const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([
cipher.update(sourceData), cipher.update(sourceData),
cipher.final() cipher.final(),
]); ]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
// Create metadata // Create metadata
const metadata: EncryptedFileMetadata = { const metadata: EncryptedFileMetadata = {
iv: iv.toString('hex'), iv: iv.toString("hex"),
tag: tag.toString('hex'), tag: tag.toString("hex"),
version: this.VERSION, version: this.VERSION,
fingerprint: HardwareFingerprint.generate().substring(0, 16), fingerprint: HardwareFingerprint.generate().substring(0, 16),
salt: salt.toString('hex'), salt: salt.toString("hex"),
algorithm: this.ALGORITHM algorithm: this.ALGORITHM,
}; };
// Write encrypted file and metadata // Write encrypted file and metadata
fs.writeFileSync(encryptedPath, encrypted); fs.writeFileSync(encryptedPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
databaseLogger.info('Database file encrypted successfully', { databaseLogger.info("Database file encrypted successfully", {
operation: 'database_file_encryption', operation: "database_file_encryption",
sourcePath, sourcePath,
encryptedPath, encryptedPath,
fileSize: sourceData.length, fileSize: sourceData.length,
encryptedSize: encrypted.length, encryptedSize: encrypted.length,
fingerprintPrefix: metadata.fingerprint fingerprintPrefix: metadata.fingerprint,
}); });
return encryptedPath; return encryptedPath;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to encrypt database file', error, { databaseLogger.error("Failed to encrypt database file", error, {
operation: 'database_file_encryption_failed', operation: "database_file_encryption_failed",
sourcePath, sourcePath,
targetPath: encryptedPath targetPath: encryptedPath,
}); });
throw new Error(`Database file encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -165,7 +153,9 @@ class DatabaseFileEncryption {
*/ */
static decryptDatabaseToBuffer(encryptedPath: string): Buffer { static decryptDatabaseToBuffer(encryptedPath: string): Buffer {
if (!fs.existsSync(encryptedPath)) { if (!fs.existsSync(encryptedPath)) {
throw new Error(`Encrypted database file does not exist: ${encryptedPath}`); throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
} }
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
@@ -175,7 +165,7 @@ class DatabaseFileEncryption {
try { try {
// Read metadata // Read metadata
const metadataContent = fs.readFileSync(metadataPath, 'utf8'); const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
// Validate metadata version // Validate metadata version
@@ -184,60 +174,59 @@ class DatabaseFileEncryption {
} }
// Validate hardware fingerprint // Validate hardware fingerprint
const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); const currentFingerprint = HardwareFingerprint.generate().substring(
0,
16,
);
if (metadata.fingerprint !== currentFingerprint) { if (metadata.fingerprint !== currentFingerprint) {
databaseLogger.warn('Hardware fingerprint mismatch for database buffer decryption', { throw new Error(
operation: 'database_buffer_decryption', "Hardware fingerprint mismatch - database was encrypted on different hardware",
expected: metadata.fingerprint, );
current: currentFingerprint
});
throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware');
} }
// Read encrypted data // Read encrypted data
const encryptedData = fs.readFileSync(encryptedPath); const encryptedData = fs.readFileSync(encryptedPath);
// Generate decryption key // Generate decryption key
const salt = Buffer.from(metadata.salt, 'hex'); const salt = Buffer.from(metadata.salt, "hex");
const key = this.generateFileEncryptionKey(salt); const key = this.generateFileEncryptionKey(salt);
// Decrypt to buffer // Decrypt to buffer
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
metadata.algorithm, metadata.algorithm,
key, key,
Buffer.from(metadata.iv, 'hex') Buffer.from(metadata.iv, "hex"),
) as any; ) as any;
decipher.setAuthTag(Buffer.from(metadata.tag, 'hex')); decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decryptedBuffer = Buffer.concat([ const decryptedBuffer = Buffer.concat([
decipher.update(encryptedData), decipher.update(encryptedData),
decipher.final() 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; return decryptedBuffer;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to decrypt database to buffer', error, { databaseLogger.error("Failed to decrypt database to buffer", error, {
operation: 'database_buffer_decryption_failed', operation: "database_buffer_decryption_failed",
encryptedPath encryptedPath,
}); });
throw new Error(`Database buffer decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
/** /**
* Decrypt database file * Decrypt database file
*/ */
static decryptDatabaseFile(encryptedPath: string, targetPath?: string): string { static decryptDatabaseFile(
encryptedPath: string,
targetPath?: string,
): string {
if (!fs.existsSync(encryptedPath)) { if (!fs.existsSync(encryptedPath)) {
throw new Error(`Encrypted database file does not exist: ${encryptedPath}`); throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
} }
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
@@ -245,11 +234,12 @@ class DatabaseFileEncryption {
throw new Error(`Metadata file does not exist: ${metadataPath}`); throw new Error(`Metadata file does not exist: ${metadataPath}`);
} }
const decryptedPath = targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, ''); const decryptedPath =
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
try { try {
// Read metadata // Read metadata
const metadataContent = fs.readFileSync(metadataPath, 'utf8'); const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
// Validate metadata version // Validate metadata version
@@ -258,56 +248,63 @@ class DatabaseFileEncryption {
} }
// Validate hardware fingerprint // Validate hardware fingerprint
const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); const currentFingerprint = HardwareFingerprint.generate().substring(
0,
16,
);
if (metadata.fingerprint !== currentFingerprint) { if (metadata.fingerprint !== currentFingerprint) {
databaseLogger.warn('Hardware fingerprint mismatch for database file', { databaseLogger.warn("Hardware fingerprint mismatch for database file", {
operation: 'database_file_decryption', operation: "database_file_decryption",
expected: metadata.fingerprint, expected: metadata.fingerprint,
current: currentFingerprint current: currentFingerprint,
}); });
throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware'); throw new Error(
"Hardware fingerprint mismatch - database was encrypted on different hardware",
);
} }
// Read encrypted data // Read encrypted data
const encryptedData = fs.readFileSync(encryptedPath); const encryptedData = fs.readFileSync(encryptedPath);
// Generate decryption key // Generate decryption key
const salt = Buffer.from(metadata.salt, 'hex'); const salt = Buffer.from(metadata.salt, "hex");
const key = this.generateFileEncryptionKey(salt); const key = this.generateFileEncryptionKey(salt);
// Decrypt the file // Decrypt the file
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
metadata.algorithm, metadata.algorithm,
key, key,
Buffer.from(metadata.iv, 'hex') Buffer.from(metadata.iv, "hex"),
) as any; ) as any;
decipher.setAuthTag(Buffer.from(metadata.tag, 'hex')); decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decrypted = Buffer.concat([ const decrypted = Buffer.concat([
decipher.update(encryptedData), decipher.update(encryptedData),
decipher.final() decipher.final(),
]); ]);
// Write decrypted file // Write decrypted file
fs.writeFileSync(decryptedPath, decrypted); fs.writeFileSync(decryptedPath, decrypted);
databaseLogger.info('Database file decrypted successfully', { databaseLogger.info("Database file decrypted successfully", {
operation: 'database_file_decryption', operation: "database_file_decryption",
encryptedPath, encryptedPath,
decryptedPath, decryptedPath,
encryptedSize: encryptedData.length, encryptedSize: encryptedData.length,
decryptedSize: decrypted.length, decryptedSize: decrypted.length,
fingerprintPrefix: metadata.fingerprint fingerprintPrefix: metadata.fingerprint,
}); });
return decryptedPath; return decryptedPath;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to decrypt database file', error, { databaseLogger.error("Failed to decrypt database file", error, {
operation: 'database_file_decryption_failed', operation: "database_file_decryption_failed",
encryptedPath, encryptedPath,
targetPath: decryptedPath targetPath: decryptedPath,
}); });
throw new Error(`Database file decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -322,9 +319,12 @@ class DatabaseFileEncryption {
} }
try { try {
const metadataContent = fs.readFileSync(metadataPath, 'utf8'); const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
return metadata.version === this.VERSION && metadata.algorithm === this.ALGORITHM; return (
metadata.version === this.VERSION &&
metadata.algorithm === this.ALGORITHM
);
} catch { } catch {
return false; return false;
} }
@@ -346,18 +346,21 @@ class DatabaseFileEncryption {
try { try {
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
const metadataContent = fs.readFileSync(metadataPath, 'utf8'); const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileStats = fs.statSync(encryptedPath); const fileStats = fs.statSync(encryptedPath);
const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); const currentFingerprint = HardwareFingerprint.generate().substring(
0,
16,
);
return { return {
version: metadata.version, version: metadata.version,
algorithm: metadata.algorithm, algorithm: metadata.algorithm,
fingerprint: metadata.fingerprint, fingerprint: metadata.fingerprint,
isCurrentHardware: metadata.fingerprint === currentFingerprint, isCurrentHardware: metadata.fingerprint === currentFingerprint,
fileSize: fileStats.size fileSize: fileStats.size,
}; };
} catch { } catch {
return null; return null;
@@ -367,7 +370,10 @@ class DatabaseFileEncryption {
/** /**
* Securely backup database by creating encrypted copy * Securely backup database by creating encrypted copy
*/ */
static createEncryptedBackup(databasePath: string, backupDir: string): string { static createEncryptedBackup(
databasePath: string,
backupDir: string,
): string {
if (!fs.existsSync(databasePath)) { if (!fs.existsSync(databasePath)) {
throw new Error(`Database file does not exist: ${databasePath}`); throw new Error(`Database file does not exist: ${databasePath}`);
} }
@@ -378,26 +384,26 @@ class DatabaseFileEncryption {
} }
// Generate backup filename with timestamp // Generate backup filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`; const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
const backupPath = path.join(backupDir, backupFileName); const backupPath = path.join(backupDir, backupFileName);
try { try {
const encryptedPath = this.encryptDatabaseFile(databasePath, backupPath); const encryptedPath = this.encryptDatabaseFile(databasePath, backupPath);
databaseLogger.info('Encrypted database backup created', { databaseLogger.info("Encrypted database backup created", {
operation: 'database_backup', operation: "database_backup",
sourcePath: databasePath, sourcePath: databasePath,
backupPath: encryptedPath, backupPath: encryptedPath,
timestamp timestamp,
}); });
return encryptedPath; return encryptedPath;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to create encrypted backup', error, { databaseLogger.error("Failed to create encrypted backup", error, {
operation: 'database_backup_failed', operation: "database_backup_failed",
sourcePath: databasePath, sourcePath: databasePath,
backupDir backupDir,
}); });
throw error; throw error;
} }
@@ -406,26 +412,29 @@ class DatabaseFileEncryption {
/** /**
* Restore database from encrypted backup * Restore database from encrypted backup
*/ */
static restoreFromEncryptedBackup(backupPath: string, targetPath: string): string { static restoreFromEncryptedBackup(
backupPath: string,
targetPath: string,
): string {
if (!this.isEncryptedDatabaseFile(backupPath)) { if (!this.isEncryptedDatabaseFile(backupPath)) {
throw new Error('Invalid encrypted backup file'); throw new Error("Invalid encrypted backup file");
} }
try { try {
const restoredPath = this.decryptDatabaseFile(backupPath, targetPath); const restoredPath = this.decryptDatabaseFile(backupPath, targetPath);
databaseLogger.info('Database restored from encrypted backup', { databaseLogger.info("Database restored from encrypted backup", {
operation: 'database_restore', operation: "database_restore",
backupPath, backupPath,
restoredPath restoredPath,
}); });
return restoredPath; return restoredPath;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to restore from encrypted backup', error, { databaseLogger.error("Failed to restore from encrypted backup", error, {
operation: 'database_restore_failed', operation: "database_restore_failed",
backupPath, backupPath,
targetPath targetPath,
}); });
throw error; throw error;
} }
@@ -451,23 +460,23 @@ class DatabaseFileEncryption {
const tempFiles = [ const tempFiles = [
`${basePath}.tmp`, `${basePath}.tmp`,
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}`, `${basePath}${this.ENCRYPTED_FILE_SUFFIX}`,
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}` `${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}`,
]; ];
for (const tempFile of tempFiles) { for (const tempFile of tempFiles) {
if (fs.existsSync(tempFile)) { if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile); fs.unlinkSync(tempFile);
databaseLogger.debug('Cleaned up temporary file', { databaseLogger.debug("Cleaned up temporary file", {
operation: 'temp_cleanup', operation: "temp_cleanup",
file: tempFile file: tempFile,
}); });
} }
} }
} catch (error) { } catch (error) {
databaseLogger.warn('Failed to clean up temporary files', { databaseLogger.warn("Failed to clean up temporary files", {
operation: 'temp_cleanup_failed', operation: "temp_cleanup_failed",
basePath, basePath,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}); });
} }
} }

View File

@@ -1,13 +1,23 @@
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import crypto from 'crypto'; import crypto from "crypto";
import { DatabaseFileEncryption } from './database-file-encryption.js'; import { DatabaseFileEncryption } from "./database-file-encryption.js";
import { DatabaseEncryption } from './database-encryption.js'; import { DatabaseEncryption } from "./database-encryption.js";
import { FieldEncryption } from './encryption.js'; import { FieldEncryption } from "./encryption.js";
import { HardwareFingerprint } from './hardware-fingerprint.js'; import { HardwareFingerprint } from "./hardware-fingerprint.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import { db, databasePaths } from '../database/db/index.js'; import { db, databasePaths } from "../database/db/index.js";
import { users, sshData, sshCredentials, settings, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, sshCredentialUsage } from '../database/db/schema.js'; import {
users,
sshData,
sshCredentials,
settings,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
sshCredentialUsage,
} from "../database/db/schema.js";
interface ExportMetadata { interface ExportMetadata {
version: string; version: string;
@@ -41,8 +51,8 @@ interface ImportResult {
* Handles both field-level and file-level encryption/decryption during migration * Handles both field-level and file-level encryption/decryption during migration
*/ */
class DatabaseMigration { class DatabaseMigration {
private static readonly VERSION = 'v1'; private static readonly VERSION = "v1";
private static readonly EXPORT_FILE_EXTENSION = '.termix-export.json'; private static readonly EXPORT_FILE_EXTENSION = ".termix-export.json";
/** /**
* Export database for migration * Export database for migration
@@ -53,28 +63,48 @@ class DatabaseMigration {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const defaultExportPath = path.join( const defaultExportPath = path.join(
databasePaths.directory, databasePaths.directory,
`termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}` `termix-export-${timestamp.replace(/[:.]/g, "-")}${this.EXPORT_FILE_EXTENSION}`,
); );
const actualExportPath = exportPath || defaultExportPath; const actualExportPath = exportPath || defaultExportPath;
try { try {
databaseLogger.info('Starting database export for migration', { databaseLogger.info("Starting database export for migration", {
operation: 'database_export', operation: "database_export",
exportId, exportId,
exportPath: actualExportPath exportPath: actualExportPath,
}); });
// Define tables to export and their encryption status // Define tables to export and their encryption status
const tablesToExport = [ const tablesToExport = [
{ name: 'users', table: users, hasEncryption: true }, { name: "users", table: users, hasEncryption: true },
{ name: 'ssh_data', table: sshData, hasEncryption: true }, { name: "ssh_data", table: sshData, hasEncryption: true },
{ name: 'ssh_credentials', table: sshCredentials, hasEncryption: true }, { name: "ssh_credentials", table: sshCredentials, hasEncryption: true },
{ name: 'settings', table: settings, hasEncryption: false }, { 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_recent",
{ name: 'file_manager_shortcuts', table: fileManagerShortcuts, hasEncryption: false }, table: fileManagerRecent,
{ name: 'dismissed_alerts', table: dismissedAlerts, hasEncryption: false }, hasEncryption: false,
{ name: 'ssh_credential_usage', table: sshCredentialUsage, 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 = { const exportData: MigrationExport = {
@@ -82,12 +112,15 @@ class DatabaseMigration {
version: this.VERSION, version: this.VERSION,
exportedAt: timestamp, exportedAt: timestamp,
exportId, exportId,
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16), sourceHardwareFingerprint: HardwareFingerprint.generate().substring(
0,
16,
),
tableCount: 0, tableCount: 0,
recordCount: 0, recordCount: 0,
encryptedFields: [] encryptedFields: [],
}, },
data: {} data: {},
}; };
let totalRecords = 0; let totalRecords = 0;
@@ -96,9 +129,9 @@ class DatabaseMigration {
for (const tableInfo of tablesToExport) { for (const tableInfo of tablesToExport) {
try { try {
databaseLogger.debug(`Exporting table: ${tableInfo.name}`, { databaseLogger.debug(`Exporting table: ${tableInfo.name}`, {
operation: 'table_export', operation: "table_export",
table: tableInfo.name, table: tableInfo.name,
hasEncryption: tableInfo.hasEncryption hasEncryption: tableInfo.hasEncryption,
}); });
// Query all records from the table // Query all records from the table
@@ -107,16 +140,20 @@ class DatabaseMigration {
// Decrypt encrypted fields if necessary // Decrypt encrypted fields if necessary
let processedRecords = records; let processedRecords = records;
if (tableInfo.hasEncryption && records.length > 0) { if (tableInfo.hasEncryption && records.length > 0) {
processedRecords = records.map(record => { processedRecords = records.map((record) => {
try { try {
return DatabaseEncryption.decryptRecord(tableInfo.name, record); return DatabaseEncryption.decryptRecord(tableInfo.name, record);
} catch (error) { } catch (error) {
databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, { databaseLogger.warn(
operation: 'export_decrypt_warning', `Failed to decrypt record in ${tableInfo.name}`,
{
operation: "export_decrypt_warning",
table: tableInfo.name, table: tableInfo.name,
recordId: (record as any).id, recordId: (record as any).id,
error: error instanceof Error ? error.message : 'Unknown error' error:
}); error instanceof Error ? error.message : "Unknown error",
},
);
// Return original record if decryption fails // Return original record if decryption fails
return record; return record;
} }
@@ -126,7 +163,9 @@ class DatabaseMigration {
if (records.length > 0) { if (records.length > 0) {
const sampleRecord = records[0]; const sampleRecord = records[0];
for (const fieldName of Object.keys(sampleRecord)) { for (const fieldName of Object.keys(sampleRecord)) {
if (FieldEncryption.shouldEncryptField(tableInfo.name, fieldName)) { if (
FieldEncryption.shouldEncryptField(tableInfo.name, fieldName)
) {
const fieldKey = `${tableInfo.name}.${fieldName}`; const fieldKey = `${tableInfo.name}.${fieldName}`;
if (!exportData.metadata.encryptedFields.includes(fieldKey)) { if (!exportData.metadata.encryptedFields.includes(fieldKey)) {
exportData.metadata.encryptedFields.push(fieldKey); exportData.metadata.encryptedFields.push(fieldKey);
@@ -140,15 +179,19 @@ class DatabaseMigration {
totalRecords += processedRecords.length; totalRecords += processedRecords.length;
databaseLogger.debug(`Table ${tableInfo.name} exported`, { databaseLogger.debug(`Table ${tableInfo.name} exported`, {
operation: 'table_export_complete', operation: "table_export_complete",
table: tableInfo.name, table: tableInfo.name,
recordCount: processedRecords.length recordCount: processedRecords.length,
}); });
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to export table ${tableInfo.name}`, error, { databaseLogger.error(
operation: 'table_export_failed', `Failed to export table ${tableInfo.name}`,
table: tableInfo.name error,
}); {
operation: "table_export_failed",
table: tableInfo.name,
},
);
throw error; throw error;
} }
} }
@@ -159,25 +202,27 @@ class DatabaseMigration {
// Write export file // Write export file
const exportContent = JSON.stringify(exportData, null, 2); const exportContent = JSON.stringify(exportData, null, 2);
fs.writeFileSync(actualExportPath, exportContent, 'utf8'); fs.writeFileSync(actualExportPath, exportContent, "utf8");
databaseLogger.success('Database export completed successfully', { databaseLogger.success("Database export completed successfully", {
operation: 'database_export_complete', operation: "database_export_complete",
exportId, exportId,
exportPath: actualExportPath, exportPath: actualExportPath,
tableCount: exportData.metadata.tableCount, tableCount: exportData.metadata.tableCount,
recordCount: exportData.metadata.recordCount, recordCount: exportData.metadata.recordCount,
fileSize: exportContent.length fileSize: exportContent.length,
}); });
return actualExportPath; return actualExportPath;
} catch (error) { } catch (error) {
databaseLogger.error('Database export failed', error, { databaseLogger.error("Database export failed", error, {
operation: 'database_export_failed', operation: "database_export_failed",
exportId, exportId,
exportPath: actualExportPath exportPath: actualExportPath,
}); });
throw new Error(`Database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -185,10 +230,13 @@ class DatabaseMigration {
* Import database from migration export * Import database from migration export
* Re-encrypts fields for the current hardware * Re-encrypts fields for the current hardware
*/ */
static async importDatabase(importPath: string, options: { static async importDatabase(
importPath: string,
options: {
replaceExisting?: boolean; replaceExisting?: boolean;
backupCurrent?: boolean; backupCurrent?: boolean;
} = {}): Promise<ImportResult> { } = {},
): Promise<ImportResult> {
const { replaceExisting = false, backupCurrent = true } = options; const { replaceExisting = false, backupCurrent = true } = options;
if (!fs.existsSync(importPath)) { if (!fs.existsSync(importPath)) {
@@ -196,43 +244,45 @@ class DatabaseMigration {
} }
try { try {
databaseLogger.info('Starting database import from migration export', { databaseLogger.info("Starting database import from migration export", {
operation: 'database_import', operation: "database_import",
importPath, importPath,
replaceExisting, replaceExisting,
backupCurrent backupCurrent,
}); });
// Read and validate export file // Read and validate export file
const exportContent = fs.readFileSync(importPath, 'utf8'); const exportContent = fs.readFileSync(importPath, "utf8");
const exportData: MigrationExport = JSON.parse(exportContent); const exportData: MigrationExport = JSON.parse(exportContent);
// Validate export format // Validate export format
if (exportData.metadata.version !== this.VERSION) { if (exportData.metadata.version !== this.VERSION) {
throw new Error(`Unsupported export version: ${exportData.metadata.version}`); throw new Error(
`Unsupported export version: ${exportData.metadata.version}`,
);
} }
const result: ImportResult = { const result: ImportResult = {
success: false, success: false,
imported: { tables: 0, records: 0 }, imported: { tables: 0, records: 0 },
errors: [], errors: [],
warnings: [] warnings: [],
}; };
// Create backup if requested // Create backup if requested
if (backupCurrent) { if (backupCurrent) {
try { try {
const backupPath = await this.createCurrentDatabaseBackup(); const backupPath = await this.createCurrentDatabaseBackup();
databaseLogger.info('Current database backed up before import', { databaseLogger.info("Current database backed up before import", {
operation: 'import_backup', operation: "import_backup",
backupPath backupPath,
}); });
} catch (error) { } catch (error) {
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`; const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : "Unknown error"}`;
result.warnings.push(warningMsg); result.warnings.push(warningMsg);
databaseLogger.warn('Failed to create pre-import backup', { databaseLogger.warn("Failed to create pre-import backup", {
operation: 'import_backup_failed', operation: "import_backup_failed",
error: warningMsg error: warningMsg,
}); });
} }
} }
@@ -241,9 +291,9 @@ class DatabaseMigration {
for (const [tableName, tableData] of Object.entries(exportData.data)) { for (const [tableName, tableData] of Object.entries(exportData.data)) {
try { try {
databaseLogger.debug(`Importing table: ${tableName}`, { databaseLogger.debug(`Importing table: ${tableName}`, {
operation: 'table_import', operation: "table_import",
table: tableName, table: tableName,
recordCount: tableData.length recordCount: tableData.length,
}); });
if (replaceExisting) { if (replaceExisting) {
@@ -252,8 +302,8 @@ class DatabaseMigration {
if (tableSchema) { if (tableSchema) {
await db.delete(tableSchema); await db.delete(tableSchema);
databaseLogger.debug(`Cleared existing data from ${tableName}`, { databaseLogger.debug(`Cleared existing data from ${tableName}`, {
operation: 'table_clear', operation: "table_clear",
table: tableName table: tableName,
}); });
} }
} }
@@ -262,7 +312,10 @@ class DatabaseMigration {
for (const record of tableData) { for (const record of tableData) {
try { try {
// Re-encrypt sensitive fields for current hardware // Re-encrypt sensitive fields for current hardware
const processedRecord = DatabaseEncryption.encryptRecord(tableName, record); const processedRecord = DatabaseEncryption.encryptRecord(
tableName,
record,
);
// Insert record // Insert record
const tableSchema = this.getTableSchema(tableName); const tableSchema = this.getTableSchema(tableName);
@@ -270,12 +323,12 @@ class DatabaseMigration {
await db.insert(tableSchema).values(processedRecord); await db.insert(tableSchema).values(processedRecord);
} }
} catch (error) { } catch (error) {
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
databaseLogger.error('Failed to import record', error, { databaseLogger.error("Failed to import record", error, {
operation: 'record_import_failed', operation: "record_import_failed",
table: tableName, table: tableName,
recordId: record.id recordId: record.id,
}); });
} }
} }
@@ -284,16 +337,16 @@ class DatabaseMigration {
result.imported.records += tableData.length; result.imported.records += tableData.length;
databaseLogger.debug(`Table ${tableName} imported`, { databaseLogger.debug(`Table ${tableName} imported`, {
operation: 'table_import_complete', operation: "table_import_complete",
table: tableName, table: tableName,
recordCount: tableData.length recordCount: tableData.length,
}); });
} catch (error) { } catch (error) {
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
databaseLogger.error('Failed to import table', error, { databaseLogger.error("Failed to import table", error, {
operation: 'table_import_failed', operation: "table_import_failed",
table: tableName table: tableName,
}); });
} }
} }
@@ -302,31 +355,37 @@ class DatabaseMigration {
result.success = result.errors.length === 0; result.success = result.errors.length === 0;
if (result.success) { if (result.success) {
databaseLogger.success('Database import completed successfully', { databaseLogger.success("Database import completed successfully", {
operation: 'database_import_complete', operation: "database_import_complete",
importPath, importPath,
tablesImported: result.imported.tables, tablesImported: result.imported.tables,
recordsImported: result.imported.records, recordsImported: result.imported.records,
warnings: result.warnings.length warnings: result.warnings.length,
}); });
} else { } else {
databaseLogger.error('Database import completed with errors', undefined, { databaseLogger.error(
operation: 'database_import_partial', "Database import completed with errors",
undefined,
{
operation: "database_import_partial",
importPath, importPath,
tablesImported: result.imported.tables, tablesImported: result.imported.tables,
recordsImported: result.imported.records, recordsImported: result.imported.records,
errorCount: result.errors.length, errorCount: result.errors.length,
warningCount: result.warnings.length warningCount: result.warnings.length,
}); },
);
} }
return result; return result;
} catch (error) { } catch (error) {
databaseLogger.error('Database import failed', error, { databaseLogger.error("Database import failed", error, {
operation: 'database_import_failed', operation: "database_import_failed",
importPath importPath,
}); });
throw new Error(`Database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Database import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -341,32 +400,38 @@ class DatabaseMigration {
const result = { const result = {
valid: false, valid: false,
metadata: undefined as ExportMetadata | undefined, metadata: undefined as ExportMetadata | undefined,
errors: [] as string[] errors: [] as string[],
}; };
try { try {
if (!fs.existsSync(exportPath)) { if (!fs.existsSync(exportPath)) {
result.errors.push('Export file does not exist'); result.errors.push("Export file does not exist");
return result; return result;
} }
const exportContent = fs.readFileSync(exportPath, 'utf8'); const exportContent = fs.readFileSync(exportPath, "utf8");
const exportData: MigrationExport = JSON.parse(exportContent); const exportData: MigrationExport = JSON.parse(exportContent);
// Validate structure // Validate structure
if (!exportData.metadata || !exportData.data) { if (!exportData.metadata || !exportData.data) {
result.errors.push('Invalid export file structure'); result.errors.push("Invalid export file structure");
return result; return result;
} }
// Validate version // Validate version
if (exportData.metadata.version !== this.VERSION) { if (exportData.metadata.version !== this.VERSION) {
result.errors.push(`Unsupported export version: ${exportData.metadata.version}`); result.errors.push(
`Unsupported export version: ${exportData.metadata.version}`,
);
return result; return result;
} }
// Validate required metadata fields // Validate required metadata fields
const requiredFields = ['exportedAt', 'exportId', 'sourceHardwareFingerprint']; const requiredFields = [
"exportedAt",
"exportId",
"sourceHardwareFingerprint",
];
for (const field of requiredFields) { for (const field of requiredFields) {
if (!exportData.metadata[field as keyof ExportMetadata]) { if (!exportData.metadata[field as keyof ExportMetadata]) {
result.errors.push(`Missing required metadata field: ${field}`); result.errors.push(`Missing required metadata field: ${field}`);
@@ -380,7 +445,9 @@ class DatabaseMigration {
return result; return result;
} catch (error) { } catch (error) {
result.errors.push(`Failed to parse export file: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(
`Failed to parse export file: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return result; return result;
} }
} }
@@ -389,8 +456,8 @@ class DatabaseMigration {
* Create backup of current database * Create backup of current database
*/ */
private static async createCurrentDatabaseBackup(): Promise<string> { private static async createCurrentDatabaseBackup(): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir = path.join(databasePaths.directory, 'backups'); const backupDir = path.join(databasePaths.directory, "backups");
if (!fs.existsSync(backupDir)) { if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true }); fs.mkdirSync(backupDir, { recursive: true });
@@ -399,7 +466,7 @@ class DatabaseMigration {
// Create encrypted backup // Create encrypted backup
const backupPath = DatabaseFileEncryption.createEncryptedBackup( const backupPath = DatabaseFileEncryption.createEncryptedBackup(
databasePaths.main, databasePaths.main,
backupDir backupDir,
); );
return backupPath; return backupPath;
@@ -410,15 +477,15 @@ class DatabaseMigration {
*/ */
private static getTableSchema(tableName: string) { private static getTableSchema(tableName: string) {
const tableMap: { [key: string]: any } = { const tableMap: { [key: string]: any } = {
'users': users, users: users,
'ssh_data': sshData, ssh_data: sshData,
'ssh_credentials': sshCredentials, ssh_credentials: sshCredentials,
'settings': settings, settings: settings,
'file_manager_recent': fileManagerRecent, file_manager_recent: fileManagerRecent,
'file_manager_pinned': fileManagerPinned, file_manager_pinned: fileManagerPinned,
'file_manager_shortcuts': fileManagerShortcuts, file_manager_shortcuts: fileManagerShortcuts,
'dismissed_alerts': dismissedAlerts, dismissed_alerts: dismissedAlerts,
'ssh_credential_usage': sshCredentialUsage ssh_credential_usage: sshCredentialUsage,
}; };
return tableMap[tableName]; return tableMap[tableName];

View File

@@ -1,15 +1,15 @@
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import crypto from 'crypto'; import crypto from "crypto";
import Database from 'better-sqlite3'; import Database from "better-sqlite3";
import { sql, eq } from 'drizzle-orm'; import { sql, eq } from "drizzle-orm";
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { drizzle } from "drizzle-orm/better-sqlite3";
import { DatabaseEncryption } from './database-encryption.js'; import { DatabaseEncryption } from "./database-encryption.js";
import { FieldEncryption } from './encryption.js'; import { FieldEncryption } from "./encryption.js";
import { HardwareFingerprint } from './hardware-fingerprint.js'; import { HardwareFingerprint } from "./hardware-fingerprint.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import { databasePaths, db, sqliteInstance } from '../database/db/index.js'; import { databasePaths, db, sqliteInstance } from "../database/db/index.js";
import { sshData, sshCredentials, users } from '../database/db/schema.js'; import { sshData, sshCredentials, users } from "../database/db/schema.js";
interface ExportMetadata { interface ExportMetadata {
version: string; version: string;
@@ -36,9 +36,9 @@ interface ImportResult {
* Exports decrypted data to a new SQLite database file for hardware transfer * Exports decrypted data to a new SQLite database file for hardware transfer
*/ */
class DatabaseSQLiteExport { class DatabaseSQLiteExport {
private static readonly VERSION = 'v1'; private static readonly VERSION = "v1";
private static readonly EXPORT_FILE_EXTENSION = '.termix-export.sqlite'; private static readonly EXPORT_FILE_EXTENSION = ".termix-export.sqlite";
private static readonly METADATA_TABLE = '_termix_export_metadata'; private static readonly METADATA_TABLE = "_termix_export_metadata";
/** /**
* Export database as SQLite file for migration * Export database as SQLite file for migration
@@ -49,15 +49,15 @@ class DatabaseSQLiteExport {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const defaultExportPath = path.join( const defaultExportPath = path.join(
databasePaths.directory, databasePaths.directory,
`termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}` `termix-export-${timestamp.replace(/[:.]/g, "-")}${this.EXPORT_FILE_EXTENSION}`,
); );
const actualExportPath = exportPath || defaultExportPath; const actualExportPath = exportPath || defaultExportPath;
try { try {
databaseLogger.info('Starting SQLite database export for migration', { databaseLogger.info("Starting SQLite database export for migration", {
operation: 'database_sqlite_export', operation: "database_sqlite_export",
exportId, exportId,
exportPath: actualExportPath exportPath: actualExportPath,
}); });
// Create new SQLite database for export // Create new SQLite database for export
@@ -65,18 +65,21 @@ class DatabaseSQLiteExport {
// Define tables to export - only SSH-related data // Define tables to export - only SSH-related data
const tablesToExport = [ const tablesToExport = [
{ name: 'ssh_data', hasEncryption: true }, { name: "ssh_data", hasEncryption: true },
{ name: 'ssh_credentials', hasEncryption: true } { name: "ssh_credentials", hasEncryption: true },
]; ];
const exportMetadata: ExportMetadata = { const exportMetadata: ExportMetadata = {
version: this.VERSION, version: this.VERSION,
exportedAt: timestamp, exportedAt: timestamp,
exportId, exportId,
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16), sourceHardwareFingerprint: HardwareFingerprint.generate().substring(
0,
16,
),
tableCount: 0, tableCount: 0,
recordCount: 0, recordCount: 0,
encryptedFields: [] encryptedFields: [],
}; };
let totalRecords = 0; let totalRecords = 0;
@@ -86,9 +89,9 @@ class DatabaseSQLiteExport {
const totalSshCredentials = await db.select().from(sshCredentials); const totalSshCredentials = await db.select().from(sshCredentials);
databaseLogger.info(`Export preparation: found SSH data`, { databaseLogger.info(`Export preparation: found SSH data`, {
operation: 'export_data_check', operation: "export_data_check",
totalSshData: totalSshData.length, totalSshData: totalSshData.length,
totalSshCredentials: totalSshCredentials.length totalSshCredentials: totalSshCredentials.length,
}); });
// Create metadata table // Create metadata table
@@ -103,13 +106,13 @@ class DatabaseSQLiteExport {
for (const tableInfo of tablesToExport) { for (const tableInfo of tablesToExport) {
try { try {
databaseLogger.debug(`Exporting SQLite table: ${tableInfo.name}`, { databaseLogger.debug(`Exporting SQLite table: ${tableInfo.name}`, {
operation: 'table_sqlite_export', operation: "table_sqlite_export",
table: tableInfo.name, table: tableInfo.name,
hasEncryption: tableInfo.hasEncryption hasEncryption: tableInfo.hasEncryption,
}); });
// Create table in export database using consistent schema // Create table in export database using consistent schema
if (tableInfo.name === 'ssh_data') { if (tableInfo.name === "ssh_data") {
// Create ssh_data table using exact schema matching Drizzle definition // Create ssh_data table using exact schema matching Drizzle definition
const createTableSql = `CREATE TABLE ssh_data ( const createTableSql = `CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -137,7 +140,7 @@ class DatabaseSQLiteExport {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`; )`;
exportDb.exec(createTableSql); exportDb.exec(createTableSql);
} else if (tableInfo.name === 'ssh_credentials') { } else if (tableInfo.name === "ssh_credentials") {
// Create ssh_credentials table using exact schema matching Drizzle definition // Create ssh_credentials table using exact schema matching Drizzle definition
const createTableSql = `CREATE TABLE ssh_credentials ( const createTableSql = `CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -153,41 +156,48 @@ class DatabaseSQLiteExport {
exportDb.exec(createTableSql); exportDb.exec(createTableSql);
} else { } else {
databaseLogger.warn(`Unknown table ${tableInfo.name}, skipping`, { databaseLogger.warn(`Unknown table ${tableInfo.name}, skipping`, {
operation: 'table_sqlite_export_skip', operation: "table_sqlite_export_skip",
table: tableInfo.name table: tableInfo.name,
}); });
continue; continue;
} }
// Query all records from tables using Drizzle // Query all records from tables using Drizzle
let records: any[]; let records: any[];
if (tableInfo.name === 'ssh_data') { if (tableInfo.name === "ssh_data") {
records = await db.select().from(sshData); records = await db.select().from(sshData);
} else if (tableInfo.name === 'ssh_credentials') { } else if (tableInfo.name === "ssh_credentials") {
records = await db.select().from(sshCredentials); records = await db.select().from(sshCredentials);
} else { } else {
records = []; records = [];
} }
databaseLogger.info(`Found ${records.length} records in ${tableInfo.name} for export`, { databaseLogger.info(
operation: 'table_record_count', `Found ${records.length} records in ${tableInfo.name} for export`,
{
operation: "table_record_count",
table: tableInfo.name, table: tableInfo.name,
recordCount: records.length recordCount: records.length,
}); },
);
// Decrypt encrypted fields if necessary // Decrypt encrypted fields if necessary
let processedRecords = records; let processedRecords = records;
if (tableInfo.hasEncryption && records.length > 0) { if (tableInfo.hasEncryption && records.length > 0) {
processedRecords = records.map(record => { processedRecords = records.map((record) => {
try { try {
return DatabaseEncryption.decryptRecord(tableInfo.name, record); return DatabaseEncryption.decryptRecord(tableInfo.name, record);
} catch (error) { } catch (error) {
databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, { databaseLogger.warn(
operation: 'export_decrypt_warning', `Failed to decrypt record in ${tableInfo.name}`,
{
operation: "export_decrypt_warning",
table: tableInfo.name, table: tableInfo.name,
recordId: (record as any).id, recordId: (record as any).id,
error: error instanceof Error ? error.message : 'Unknown error' error:
}); error instanceof Error ? error.message : "Unknown error",
},
);
return record; return record;
} }
}); });
@@ -210,40 +220,44 @@ class DatabaseSQLiteExport {
const tsFieldNames = Object.keys(sampleRecord); const tsFieldNames = Object.keys(sampleRecord);
// Map TypeScript field names to database column names // Map TypeScript field names to database column names
const dbColumnNames = tsFieldNames.map(fieldName => { const dbColumnNames = tsFieldNames.map((fieldName) => {
// Map TypeScript field names to database column names // Map TypeScript field names to database column names
const fieldMappings: Record<string, string> = { const fieldMappings: Record<string, string> = {
'userId': 'user_id', userId: "user_id",
'authType': 'auth_type', authType: "auth_type",
'requirePassword': 'require_password', requirePassword: "require_password",
'keyPassword': 'key_password', keyPassword: "key_password",
'keyType': 'key_type', keyType: "key_type",
'credentialId': 'credential_id', credentialId: "credential_id",
'enableTerminal': 'enable_terminal', enableTerminal: "enable_terminal",
'enableTunnel': 'enable_tunnel', enableTunnel: "enable_tunnel",
'tunnelConnections': 'tunnel_connections', tunnelConnections: "tunnel_connections",
'enableFileManager': 'enable_file_manager', enableFileManager: "enable_file_manager",
'defaultPath': 'default_path', defaultPath: "default_path",
'createdAt': 'created_at', createdAt: "created_at",
'updatedAt': 'updated_at', updatedAt: "updated_at",
'keyContent': 'key_content' keyContent: "key_content",
}; };
return fieldMappings[fieldName] || fieldName; return fieldMappings[fieldName] || fieldName;
}); });
const placeholders = dbColumnNames.map(() => '?').join(', '); const placeholders = dbColumnNames.map(() => "?").join(", ");
const insertSql = `INSERT INTO ${tableInfo.name} (${dbColumnNames.join(', ')}) VALUES (${placeholders})`; const insertSql = `INSERT INTO ${tableInfo.name} (${dbColumnNames.join(", ")}) VALUES (${placeholders})`;
const insertStmt = exportDb.prepare(insertSql); const insertStmt = exportDb.prepare(insertSql);
for (const record of processedRecords) { for (const record of processedRecords) {
const values = tsFieldNames.map(fieldName => { const values = tsFieldNames.map((fieldName) => {
const value: any = record[fieldName as keyof typeof record]; const value: any = record[fieldName as keyof typeof record];
// Convert values to SQLite-compatible types // Convert values to SQLite-compatible types
if (value === null || value === undefined) { if (value === null || value === undefined) {
return null; return null;
} }
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') { if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "bigint"
) {
return value; return value;
} }
if (Buffer.isBuffer(value)) { if (Buffer.isBuffer(value)) {
@@ -252,11 +266,11 @@ class DatabaseSQLiteExport {
if (value instanceof Date) { if (value instanceof Date) {
return value.toISOString(); return value.toISOString();
} }
if (typeof value === 'boolean') { if (typeof value === "boolean") {
return value ? 1 : 0; return value ? 1 : 0;
} }
// Convert objects and arrays to JSON strings // Convert objects and arrays to JSON strings
if (typeof value === 'object') { if (typeof value === "object") {
return JSON.stringify(value); return JSON.stringify(value);
} }
// Fallback: convert to string // Fallback: convert to string
@@ -269,15 +283,19 @@ class DatabaseSQLiteExport {
totalRecords += processedRecords.length; totalRecords += processedRecords.length;
databaseLogger.debug(`SQLite table ${tableInfo.name} exported`, { databaseLogger.debug(`SQLite table ${tableInfo.name} exported`, {
operation: 'table_sqlite_export_complete', operation: "table_sqlite_export_complete",
table: tableInfo.name, table: tableInfo.name,
recordCount: processedRecords.length recordCount: processedRecords.length,
}); });
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to export SQLite table ${tableInfo.name}`, error, { databaseLogger.error(
operation: 'table_sqlite_export_failed', `Failed to export SQLite table ${tableInfo.name}`,
table: tableInfo.name error,
}); {
operation: "table_sqlite_export_failed",
table: tableInfo.name,
},
);
throw error; throw error;
} }
} }
@@ -286,29 +304,33 @@ class DatabaseSQLiteExport {
exportMetadata.tableCount = tablesToExport.length; exportMetadata.tableCount = tablesToExport.length;
exportMetadata.recordCount = totalRecords; exportMetadata.recordCount = totalRecords;
const insertMetadata = exportDb.prepare(`INSERT INTO ${this.METADATA_TABLE} (key, value) VALUES (?, ?)`); const insertMetadata = exportDb.prepare(
insertMetadata.run('metadata', JSON.stringify(exportMetadata)); `INSERT INTO ${this.METADATA_TABLE} (key, value) VALUES (?, ?)`,
);
insertMetadata.run("metadata", JSON.stringify(exportMetadata));
// Close export database // Close export database
exportDb.close(); exportDb.close();
databaseLogger.success('SQLite database export completed successfully', { databaseLogger.success("SQLite database export completed successfully", {
operation: 'database_sqlite_export_complete', operation: "database_sqlite_export_complete",
exportId, exportId,
exportPath: actualExportPath, exportPath: actualExportPath,
tableCount: exportMetadata.tableCount, tableCount: exportMetadata.tableCount,
recordCount: exportMetadata.recordCount, recordCount: exportMetadata.recordCount,
fileSize: fs.statSync(actualExportPath).size fileSize: fs.statSync(actualExportPath).size,
}); });
return actualExportPath; return actualExportPath;
} catch (error) { } catch (error) {
databaseLogger.error('SQLite database export failed', error, { databaseLogger.error("SQLite database export failed", error, {
operation: 'database_sqlite_export_failed', operation: "database_sqlite_export_failed",
exportId, exportId,
exportPath: actualExportPath exportPath: actualExportPath,
}); });
throw new Error(`SQLite database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`SQLite database export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -316,10 +338,13 @@ class DatabaseSQLiteExport {
* Import database from SQLite export * Import database from SQLite export
* Re-encrypts fields for the current hardware * Re-encrypts fields for the current hardware
*/ */
static async importDatabase(importPath: string, options: { static async importDatabase(
importPath: string,
options: {
replaceExisting?: boolean; replaceExisting?: boolean;
backupCurrent?: boolean; backupCurrent?: boolean;
} = {}): Promise<ImportResult> { } = {},
): Promise<ImportResult> {
const { replaceExisting = false, backupCurrent = true } = options; const { replaceExisting = false, backupCurrent = true } = options;
if (!fs.existsSync(importPath)) { if (!fs.existsSync(importPath)) {
@@ -327,23 +352,27 @@ class DatabaseSQLiteExport {
} }
try { try {
databaseLogger.info('Starting SQLite database import from export', { databaseLogger.info("Starting SQLite database import from export", {
operation: 'database_sqlite_import', operation: "database_sqlite_import",
importPath, importPath,
replaceExisting, replaceExisting,
backupCurrent backupCurrent,
}); });
// Open import database // Open import database
const importDb = new Database(importPath, { readonly: true }); const importDb = new Database(importPath, { readonly: true });
// Validate export format // Validate export format
const metadataResult = importDb.prepare(` const metadataResult = importDb
.prepare(
`
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata' SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
`).get() as { value: string } | undefined; `,
)
.get() as { value: string } | undefined;
if (!metadataResult) { if (!metadataResult) {
throw new Error('Invalid export file: missing metadata'); throw new Error("Invalid export file: missing metadata");
} }
const metadata: ExportMetadata = JSON.parse(metadataResult.value); const metadata: ExportMetadata = JSON.parse(metadataResult.value);
@@ -355,44 +384,55 @@ class DatabaseSQLiteExport {
success: false, success: false,
imported: { tables: 0, records: 0 }, imported: { tables: 0, records: 0 },
errors: [], errors: [],
warnings: [] warnings: [],
}; };
// Get current admin user to assign imported SSH records // Get current admin user to assign imported SSH records
const adminUser = await db.select().from(users).where(eq(users.is_admin, true)).limit(1); const adminUser = await db
.select()
.from(users)
.where(eq(users.is_admin, true))
.limit(1);
if (adminUser.length === 0) { if (adminUser.length === 0) {
throw new Error('No admin user found in current database'); throw new Error("No admin user found in current database");
} }
const currentAdminUserId = adminUser[0].id; const currentAdminUserId = adminUser[0].id;
databaseLogger.debug(`Starting SSH data import - assigning to admin user ${currentAdminUserId}`, { databaseLogger.debug(
operation: 'ssh_data_import_start', `Starting SSH data import - assigning to admin user ${currentAdminUserId}`,
adminUserId: currentAdminUserId {
}); operation: "ssh_data_import_start",
adminUserId: currentAdminUserId,
},
);
// Create backup if requested // Create backup if requested
if (backupCurrent) { if (backupCurrent) {
try { try {
const backupPath = await this.createCurrentDatabaseBackup(); const backupPath = await this.createCurrentDatabaseBackup();
databaseLogger.info('Current database backed up before import', { databaseLogger.info("Current database backed up before import", {
operation: 'import_backup', operation: "import_backup",
backupPath backupPath,
}); });
} catch (error) { } catch (error) {
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`; const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : "Unknown error"}`;
result.warnings.push(warningMsg); result.warnings.push(warningMsg);
databaseLogger.warn('Failed to create pre-import backup', { databaseLogger.warn("Failed to create pre-import backup", {
operation: 'import_backup_failed', operation: "import_backup_failed",
error: warningMsg error: warningMsg,
}); });
} }
} }
// Get list of tables to import (excluding metadata table) // Get list of tables to import (excluding metadata table)
const tables = importDb.prepare(` const tables = importDb
.prepare(
`
SELECT name FROM sqlite_master SELECT name FROM sqlite_master
WHERE type='table' AND name != '${this.METADATA_TABLE}' WHERE type='table' AND name != '${this.METADATA_TABLE}'
`).all() as { name: string }[]; `,
)
.all() as { name: string }[];
// Import data table by table // Import data table by table
for (const tableRow of tables) { for (const tableRow of tables) {
@@ -400,15 +440,15 @@ class DatabaseSQLiteExport {
try { try {
databaseLogger.debug(`Importing SQLite table: ${tableName}`, { databaseLogger.debug(`Importing SQLite table: ${tableName}`, {
operation: 'table_sqlite_import', operation: "table_sqlite_import",
table: tableName table: tableName,
}); });
// Use additive import - don't clear existing data // Use additive import - don't clear existing data
// This preserves all current data including admin SSH connections // This preserves all current data including admin SSH connections
databaseLogger.debug(`Using additive import for ${tableName}`, { databaseLogger.debug(`Using additive import for ${tableName}`, {
operation: 'table_additive_import', operation: "table_additive_import",
table: tableName table: tableName,
}); });
// Get all records from import table // Get all records from import table
@@ -422,20 +462,20 @@ class DatabaseSQLiteExport {
// Map database column names to TypeScript field names // Map database column names to TypeScript field names
const mappedRecord: any = {}; const mappedRecord: any = {};
const columnToFieldMappings: Record<string, string> = { const columnToFieldMappings: Record<string, string> = {
'user_id': 'userId', user_id: "userId",
'auth_type': 'authType', auth_type: "authType",
'require_password': 'requirePassword', require_password: "requirePassword",
'key_password': 'keyPassword', key_password: "keyPassword",
'key_type': 'keyType', key_type: "keyType",
'credential_id': 'credentialId', credential_id: "credentialId",
'enable_terminal': 'enableTerminal', enable_terminal: "enableTerminal",
'enable_tunnel': 'enableTunnel', enable_tunnel: "enableTunnel",
'tunnel_connections': 'tunnelConnections', tunnel_connections: "tunnelConnections",
'enable_file_manager': 'enableFileManager', enable_file_manager: "enableFileManager",
'default_path': 'defaultPath', default_path: "defaultPath",
'created_at': 'createdAt', created_at: "createdAt",
'updated_at': 'updatedAt', updated_at: "updatedAt",
'key_content': 'keyContent' key_content: "keyContent",
}; };
// Convert database column names to TypeScript field names // Convert database column names to TypeScript field names
@@ -445,44 +485,62 @@ class DatabaseSQLiteExport {
} }
// Assign imported SSH records to current admin user to avoid foreign key constraint // Assign imported SSH records to current admin user to avoid foreign key constraint
if (tableName === 'ssh_data' && mappedRecord.userId) { if (tableName === "ssh_data" && mappedRecord.userId) {
const originalUserId = mappedRecord.userId; const originalUserId = mappedRecord.userId;
mappedRecord.userId = currentAdminUserId; mappedRecord.userId = currentAdminUserId;
databaseLogger.debug(`Reassigned SSH record from user ${originalUserId} to admin ${currentAdminUserId}`, { databaseLogger.debug(
operation: 'user_reassignment', `Reassigned SSH record from user ${originalUserId} to admin ${currentAdminUserId}`,
{
operation: "user_reassignment",
originalUserId, originalUserId,
newUserId: currentAdminUserId newUserId: currentAdminUserId,
}); },
);
} }
// Re-encrypt sensitive fields for current hardware // Re-encrypt sensitive fields for current hardware
const processedRecord = DatabaseEncryption.encryptRecord(tableName, mappedRecord); const processedRecord = DatabaseEncryption.encryptRecord(
tableName,
mappedRecord,
);
// Insert record using Drizzle // Insert record using Drizzle
try { try {
if (tableName === 'ssh_data') { if (tableName === "ssh_data") {
await db.insert(sshData).values(processedRecord).onConflictDoNothing(); await db
} else if (tableName === 'ssh_credentials') { .insert(sshData)
await db.insert(sshCredentials).values(processedRecord).onConflictDoNothing(); .values(processedRecord)
.onConflictDoNothing();
} else if (tableName === "ssh_credentials") {
await db
.insert(sshCredentials)
.values(processedRecord)
.onConflictDoNothing();
} }
} catch (error) { } catch (error) {
// Handle any SQL errors gracefully // Handle any SQL errors gracefully
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { if (
databaseLogger.debug(`Skipping duplicate record in ${tableName}`, { error instanceof Error &&
operation: 'duplicate_record_skip', error.message.includes("UNIQUE constraint failed")
table: tableName ) {
}); databaseLogger.debug(
`Skipping duplicate record in ${tableName}`,
{
operation: "duplicate_record_skip",
table: tableName,
},
);
continue; continue;
} }
throw error; throw error;
} }
} catch (error) { } catch (error) {
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
databaseLogger.error('Failed to import record', error, { databaseLogger.error("Failed to import record", error, {
operation: 'record_sqlite_import_failed', operation: "record_sqlite_import_failed",
table: tableName, table: tableName,
recordId: (record as any).id recordId: (record as any).id,
}); });
} }
} }
@@ -491,16 +549,16 @@ class DatabaseSQLiteExport {
result.imported.records += records.length; result.imported.records += records.length;
databaseLogger.debug(`SQLite table ${tableName} imported`, { databaseLogger.debug(`SQLite table ${tableName} imported`, {
operation: 'table_sqlite_import_complete', operation: "table_sqlite_import_complete",
table: tableName, table: tableName,
recordCount: records.length recordCount: records.length,
}); });
} catch (error) { } catch (error) {
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
databaseLogger.error('Failed to import SQLite table', error, { databaseLogger.error("Failed to import SQLite table", error, {
operation: 'table_sqlite_import_failed', operation: "table_sqlite_import_failed",
table: tableName table: tableName,
}); });
} }
} }
@@ -512,31 +570,40 @@ class DatabaseSQLiteExport {
result.success = result.errors.length === 0; result.success = result.errors.length === 0;
if (result.success) { if (result.success) {
databaseLogger.success('SQLite database import completed successfully', { databaseLogger.success(
operation: 'database_sqlite_import_complete', "SQLite database import completed successfully",
{
operation: "database_sqlite_import_complete",
importPath, importPath,
tablesImported: result.imported.tables, tablesImported: result.imported.tables,
recordsImported: result.imported.records, recordsImported: result.imported.records,
warnings: result.warnings.length warnings: result.warnings.length,
}); },
);
} else { } else {
databaseLogger.error('SQLite database import completed with errors', undefined, { databaseLogger.error(
operation: 'database_sqlite_import_partial', "SQLite database import completed with errors",
undefined,
{
operation: "database_sqlite_import_partial",
importPath, importPath,
tablesImported: result.imported.tables, tablesImported: result.imported.tables,
recordsImported: result.imported.records, recordsImported: result.imported.records,
errorCount: result.errors.length, errorCount: result.errors.length,
warningCount: result.warnings.length warningCount: result.warnings.length,
}); },
);
} }
return result; return result;
} catch (error) { } catch (error) {
databaseLogger.error('SQLite database import failed', error, { databaseLogger.error("SQLite database import failed", error, {
operation: 'database_sqlite_import_failed', operation: "database_sqlite_import_failed",
importPath importPath,
}); });
throw new Error(`SQLite database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`SQLite database import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -551,29 +618,33 @@ class DatabaseSQLiteExport {
const result = { const result = {
valid: false, valid: false,
metadata: undefined as ExportMetadata | undefined, metadata: undefined as ExportMetadata | undefined,
errors: [] as string[] errors: [] as string[],
}; };
try { try {
if (!fs.existsSync(exportPath)) { if (!fs.existsSync(exportPath)) {
result.errors.push('Export file does not exist'); result.errors.push("Export file does not exist");
return result; return result;
} }
if (!exportPath.endsWith(this.EXPORT_FILE_EXTENSION)) { if (!exportPath.endsWith(this.EXPORT_FILE_EXTENSION)) {
result.errors.push('Invalid export file extension'); result.errors.push("Invalid export file extension");
return result; return result;
} }
const exportDb = new Database(exportPath, { readonly: true }); const exportDb = new Database(exportPath, { readonly: true });
try { try {
const metadataResult = exportDb.prepare(` const metadataResult = exportDb
.prepare(
`
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata' SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
`).get() as { value: string } | undefined; `,
)
.get() as { value: string } | undefined;
if (!metadataResult) { if (!metadataResult) {
result.errors.push('Missing export metadata'); result.errors.push("Missing export metadata");
return result; return result;
} }
@@ -592,7 +663,9 @@ class DatabaseSQLiteExport {
return result; return result;
} catch (error) { } catch (error) {
result.errors.push(`Failed to validate export file: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(
`Failed to validate export file: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return result; return result;
} }
} }
@@ -609,15 +682,18 @@ class DatabaseSQLiteExport {
* Create backup of current database * Create backup of current database
*/ */
private static async createCurrentDatabaseBackup(): Promise<string> { private static async createCurrentDatabaseBackup(): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir = path.join(databasePaths.directory, 'backups'); const backupDir = path.join(databasePaths.directory, "backups");
if (!fs.existsSync(backupDir)) { if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true }); fs.mkdirSync(backupDir, { recursive: true });
} }
// Create SQLite backup // Create SQLite backup
const backupPath = path.join(backupDir, `database-backup-${timestamp}.sqlite`); const backupPath = path.join(
backupDir,
`database-backup-${timestamp}.sqlite`,
);
// Copy current database file // Copy current database file
fs.copyFileSync(databasePaths.main, backupPath); fs.copyFileSync(databasePaths.main, backupPath);
@@ -636,7 +712,10 @@ class DatabaseSQLiteExport {
/** /**
* Check if a field should be tracked as encrypted * Check if a field should be tracked as encrypted
*/ */
private static shouldTrackEncryptedField(tableName: string, fieldName: string): boolean { private static shouldTrackEncryptedField(
tableName: string,
fieldName: string,
): boolean {
try { try {
return FieldEncryption.shouldEncryptField(tableName, fieldName); return FieldEncryption.shouldEncryptField(tableName, fieldName);
} catch { } catch {

View File

@@ -1,83 +1,93 @@
import { db } from '../database/db/index.js'; import { db } from "../database/db/index.js";
import { DatabaseEncryption } from './database-encryption.js'; import { DatabaseEncryption } from "./database-encryption.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = 'users' | 'ssh_data' | 'ssh_credentials'; type TableName = "users" | "ssh_data" | "ssh_credentials";
class EncryptedDBOperations { class EncryptedDBOperations {
static async insert<T extends Record<string, any>>( static async insert<T extends Record<string, any>>(
table: SQLiteTable<any>, table: SQLiteTable<any>,
tableName: TableName, tableName: TableName,
data: T data: T,
): Promise<T> { ): Promise<T> {
try { try {
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data); const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
const result = await db.insert(table).values(encryptedData).returning(); const result = await db.insert(table).values(encryptedData).returning();
// Decrypt the returned data to ensure consistency // Decrypt the returned data to ensure consistency
const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result[0]); const decryptedResult = DatabaseEncryption.decryptRecord(
tableName,
result[0],
);
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, { databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
operation: 'encrypted_insert', operation: "encrypted_insert",
table: tableName table: tableName,
}); });
return decryptedResult as T; return decryptedResult as T;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to insert encrypted record into ${tableName}`, error, { databaseLogger.error(
operation: 'encrypted_insert_failed', `Failed to insert encrypted record into ${tableName}`,
table: tableName error,
}); {
operation: "encrypted_insert_failed",
table: tableName,
},
);
throw error; throw error;
} }
} }
static async select<T extends Record<string, any>>( static async select<T extends Record<string, any>>(
query: any, query: any,
tableName: TableName tableName: TableName,
): Promise<T[]> { ): Promise<T[]> {
try { try {
const results = await query; const results = await query;
const decryptedResults = DatabaseEncryption.decryptRecords(tableName, results); const decryptedResults = DatabaseEncryption.decryptRecords(
tableName,
databaseLogger.debug(`Selected and decrypted ${decryptedResults.length} records from ${tableName}`, { results,
operation: 'encrypted_select', );
table: tableName,
count: decryptedResults.length
});
return decryptedResults; return decryptedResults;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to select/decrypt records from ${tableName}`, error, { databaseLogger.error(
operation: 'encrypted_select_failed', `Failed to select/decrypt records from ${tableName}`,
table: tableName error,
}); {
operation: "encrypted_select_failed",
table: tableName,
},
);
throw error; throw error;
} }
} }
static async selectOne<T extends Record<string, any>>( static async selectOne<T extends Record<string, any>>(
query: any, query: any,
tableName: TableName tableName: TableName,
): Promise<T | undefined> { ): Promise<T | undefined> {
try { try {
const result = await query; const result = await query;
if (!result) return undefined; if (!result) return undefined;
const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result); const decryptedResult = DatabaseEncryption.decryptRecord(
tableName,
databaseLogger.debug(`Selected and decrypted single record from ${tableName}`, { result,
operation: 'encrypted_select_one', );
table: tableName
});
return decryptedResult; return decryptedResult;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to select/decrypt single record from ${tableName}`, error, { databaseLogger.error(
operation: 'encrypted_select_one_failed', `Failed to select/decrypt single record from ${tableName}`,
table: tableName error,
}); {
operation: "encrypted_select_one_failed",
table: tableName,
},
);
throw error; throw error;
} }
} }
@@ -86,23 +96,31 @@ class EncryptedDBOperations {
table: SQLiteTable<any>, table: SQLiteTable<any>,
tableName: TableName, tableName: TableName,
where: any, where: any,
data: Partial<T> data: Partial<T>,
): Promise<T[]> { ): Promise<T[]> {
try { try {
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data); const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
const result = await db.update(table).set(encryptedData).where(where).returning(); const result = await db
.update(table)
.set(encryptedData)
.where(where)
.returning();
databaseLogger.debug(`Updated encrypted record in ${tableName}`, { databaseLogger.debug(`Updated encrypted record in ${tableName}`, {
operation: 'encrypted_update', operation: "encrypted_update",
table: tableName table: tableName,
}); });
return result as T[]; return result as T[];
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to update encrypted record in ${tableName}`, error, { databaseLogger.error(
operation: 'encrypted_update_failed', `Failed to update encrypted record in ${tableName}`,
table: tableName error,
}); {
operation: "encrypted_update_failed",
table: tableName,
},
);
throw error; throw error;
} }
} }
@@ -110,21 +128,21 @@ class EncryptedDBOperations {
static async delete( static async delete(
table: SQLiteTable<any>, table: SQLiteTable<any>,
tableName: TableName, tableName: TableName,
where: any where: any,
): Promise<any[]> { ): Promise<any[]> {
try { try {
const result = await db.delete(table).where(where).returning(); const result = await db.delete(table).where(where).returning();
databaseLogger.debug(`Deleted record from ${tableName}`, { databaseLogger.debug(`Deleted record from ${tableName}`, {
operation: 'encrypted_delete', operation: "encrypted_delete",
table: tableName table: tableName,
}); });
return result; return result;
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to delete record from ${tableName}`, error, { databaseLogger.error(`Failed to delete record from ${tableName}`, error, {
operation: 'encrypted_delete_failed', operation: "encrypted_delete_failed",
table: tableName table: tableName,
}); });
throw error; throw error;
} }
@@ -135,26 +153,26 @@ class EncryptedDBOperations {
try { try {
databaseLogger.info(`Starting encryption migration for ${tableName}`, { databaseLogger.info(`Starting encryption migration for ${tableName}`, {
operation: 'migration_start', operation: "migration_start",
table: tableName table: tableName,
}); });
let table: SQLiteTable<any>; let table: SQLiteTable<any>;
let records: any[]; let records: any[];
switch (tableName) { switch (tableName) {
case 'users': case "users":
const { users } = await import('../database/db/schema.js'); const { users } = await import("../database/db/schema.js");
table = users; table = users;
records = await db.select().from(users); records = await db.select().from(users);
break; break;
case 'ssh_data': case "ssh_data":
const { sshData } = await import('../database/db/schema.js'); const { sshData } = await import("../database/db/schema.js");
table = sshData; table = sshData;
records = await db.select().from(sshData); records = await db.select().from(sshData);
break; break;
case 'ssh_credentials': case "ssh_credentials":
const { sshCredentials } = await import('../database/db/schema.js'); const { sshCredentials } = await import("../database/db/schema.js");
table = sshCredentials; table = sshCredentials;
records = await db.select().from(sshCredentials); records = await db.select().from(sshCredentials);
break; break;
@@ -164,34 +182,44 @@ class EncryptedDBOperations {
for (const record of records) { for (const record of records) {
try { try {
const migratedRecord = await DatabaseEncryption.migrateRecord(tableName, record); const migratedRecord = await DatabaseEncryption.migrateRecord(
tableName,
record,
);
if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) { if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) {
const { eq } = await import('drizzle-orm'); const { eq } = await import("drizzle-orm");
await db.update(table).set(migratedRecord).where(eq((table as any).id, record.id)); await db
.update(table)
.set(migratedRecord)
.where(eq((table as any).id, record.id));
migratedCount++; migratedCount++;
} }
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to migrate record ${record.id} in ${tableName}`, error, { databaseLogger.error(
operation: 'migration_record_failed', `Failed to migrate record ${record.id} in ${tableName}`,
error,
{
operation: "migration_record_failed",
table: tableName, table: tableName,
recordId: record.id recordId: record.id,
}); },
);
} }
} }
databaseLogger.success(`Migration completed for ${tableName}`, { databaseLogger.success(`Migration completed for ${tableName}`, {
operation: 'migration_complete', operation: "migration_complete",
table: tableName, table: tableName,
migratedCount, migratedCount,
totalRecords: records.length totalRecords: records.length,
}); });
return migratedCount; return migratedCount;
} catch (error) { } catch (error) {
databaseLogger.error(`Migration failed for ${tableName}`, error, { databaseLogger.error(`Migration failed for ${tableName}`, error, {
operation: 'migration_failed', operation: "migration_failed",
table: tableName table: tableName,
}); });
throw error; throw error;
} }
@@ -202,8 +230,8 @@ class EncryptedDBOperations {
const status = DatabaseEncryption.getEncryptionStatus(); const status = DatabaseEncryption.getEncryptionStatus();
return status.configValid && status.enabled; return status.configValid && status.enabled;
} catch (error) { } catch (error) {
databaseLogger.error('Encryption health check failed', error, { databaseLogger.error("Encryption health check failed", error, {
operation: 'health_check_failed' operation: "health_check_failed",
}); });
return false; return false;
} }

View File

@@ -1,9 +1,9 @@
import crypto from 'crypto'; import crypto from "crypto";
import { db } from '../database/db/index.js'; import { db } from "../database/db/index.js";
import { settings } from '../database/db/schema.js'; import { settings } from "../database/db/schema.js";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import { MasterKeyProtection } from './master-key-protection.js'; import { MasterKeyProtection } from "./master-key-protection.js";
interface EncryptionKeyInfo { interface EncryptionKeyInfo {
hasKey: boolean; hasKey: boolean;
@@ -35,44 +35,49 @@ class EncryptionKeyManager {
return MasterKeyProtection.decryptMasterKey(encodedKey); return MasterKeyProtection.decryptMasterKey(encodedKey);
} }
databaseLogger.warn('Found legacy base64-encoded key, migrating to KEK protection', { databaseLogger.warn(
operation: 'key_migration_legacy' "Found legacy base64-encoded key, migrating to KEK protection",
}); {
const buffer = Buffer.from(encodedKey, 'base64'); operation: "key_migration_legacy",
return buffer.toString('hex'); },
);
const buffer = Buffer.from(encodedKey, "base64");
return buffer.toString("hex");
} }
async initializeKey(): Promise<string> { async initializeKey(): Promise<string> {
databaseLogger.info('Initializing encryption key system...', {
operation: 'key_init'
});
try { try {
let existingKey = await this.getStoredKey(); let existingKey = await this.getStoredKey();
if (existingKey) { if (existingKey) {
databaseLogger.success('Found existing encryption key', { databaseLogger.success("Found existing encryption key", {
operation: 'key_init', operation: "key_init",
hasKey: true hasKey: true,
}); });
this.currentKey = existingKey; this.currentKey = existingKey;
return existingKey; return existingKey;
} }
const environmentKey = process.env.DB_ENCRYPTION_KEY; const environmentKey = process.env.DB_ENCRYPTION_KEY;
if (environmentKey && environmentKey !== 'default-key-change-me') { if (environmentKey && environmentKey !== "default-key-change-me") {
if (!this.validateKeyStrength(environmentKey)) { if (!this.validateKeyStrength(environmentKey)) {
databaseLogger.error('Environment encryption key is too weak', undefined, { databaseLogger.error(
operation: 'key_init', "Environment encryption key is too weak",
source: 'environment', undefined,
keyLength: environmentKey.length {
}); operation: "key_init",
throw new Error('DB_ENCRYPTION_KEY is too weak. Must be at least 32 characters with good entropy.'); 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', { databaseLogger.info("Using encryption key from environment variable", {
operation: 'key_init', operation: "key_init",
source: 'environment' source: "environment",
}); });
await this.storeKey(environmentKey); await this.storeKey(environmentKey);
@@ -81,33 +86,35 @@ class EncryptionKeyManager {
} }
const newKey = await this.generateNewKey(); const newKey = await this.generateNewKey();
databaseLogger.warn('Generated new encryption key - PLEASE BACKUP THIS KEY', { databaseLogger.warn(
operation: 'key_init', "Generated new encryption key - PLEASE BACKUP THIS KEY",
{
operation: "key_init",
generated: true, generated: true,
keyPreview: newKey.substring(0, 8) + '...' keyPreview: newKey.substring(0, 8) + "...",
}); },
);
return newKey; return newKey;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to initialize encryption key', error, { databaseLogger.error("Failed to initialize encryption key", error, {
operation: 'key_init_failed' operation: "key_init_failed",
}); });
throw error; throw error;
} }
} }
async generateNewKey(): Promise<string> { async generateNewKey(): Promise<string> {
const newKey = crypto.randomBytes(32).toString('hex'); const newKey = crypto.randomBytes(32).toString("hex");
const keyId = crypto.randomBytes(8).toString('hex'); const keyId = crypto.randomBytes(8).toString("hex");
await this.storeKey(newKey, keyId); await this.storeKey(newKey, keyId);
this.currentKey = newKey; this.currentKey = newKey;
databaseLogger.success('Generated new encryption key', { databaseLogger.success("Generated new encryption key", {
operation: 'key_generated', operation: "key_generated",
keyId, keyId,
keyLength: newKey.length keyLength: newKey.length,
}); });
return newKey; return newKey;
@@ -115,41 +122,49 @@ class EncryptionKeyManager {
private async storeKey(key: string, keyId?: string): Promise<void> { private async storeKey(key: string, keyId?: string): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = keyId || crypto.randomBytes(8).toString('hex'); const id = keyId || crypto.randomBytes(8).toString("hex");
const keyData = { const keyData = {
key: this.encodeKey(key), key: this.encodeKey(key),
keyId: id, keyId: id,
createdAt: now, createdAt: now,
algorithm: 'aes-256-gcm' algorithm: "aes-256-gcm",
}; };
const encodedData = JSON.stringify(keyData); const encodedData = JSON.stringify(keyData);
try { try {
const existing = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); const existing = await db
.select()
.from(settings)
.where(eq(settings.key, "db_encryption_key"));
if (existing.length > 0) { if (existing.length > 0) {
await db.update(settings) await db
.update(settings)
.set({ value: encodedData }) .set({ value: encodedData })
.where(eq(settings.key, 'db_encryption_key')); .where(eq(settings.key, "db_encryption_key"));
} else { } else {
await db.insert(settings).values({ await db.insert(settings).values({
key: 'db_encryption_key', key: "db_encryption_key",
value: encodedData value: encodedData,
}); });
} }
const existingCreated = await db.select().from(settings).where(eq(settings.key, 'encryption_key_created')); const existingCreated = await db
.select()
.from(settings)
.where(eq(settings.key, "encryption_key_created"));
if (existingCreated.length > 0) { if (existingCreated.length > 0) {
await db.update(settings) await db
.update(settings)
.set({ value: now }) .set({ value: now })
.where(eq(settings.key, 'encryption_key_created')); .where(eq(settings.key, "encryption_key_created"));
} else { } else {
await db.insert(settings).values({ await db.insert(settings).values({
key: 'encryption_key_created', key: "encryption_key_created",
value: now value: now,
}); });
} }
@@ -157,12 +172,11 @@ class EncryptionKeyManager {
hasKey: true, hasKey: true,
keyId: id, keyId: id,
createdAt: now, createdAt: now,
algorithm: 'aes-256-gcm' algorithm: "aes-256-gcm",
}; };
} catch (error) { } catch (error) {
databaseLogger.error('Failed to store encryption key', error, { databaseLogger.error("Failed to store encryption key", error, {
operation: 'key_store_failed' operation: "key_store_failed",
}); });
throw error; throw error;
} }
@@ -170,7 +184,10 @@ class EncryptionKeyManager {
private async getStoredKey(): Promise<string | null> { private async getStoredKey(): Promise<string | null> {
try { try {
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); const result = await db
.select()
.from(settings)
.where(eq(settings.key, "db_encryption_key"));
if (result.length === 0) { if (result.length === 0) {
return null; return null;
@@ -182,34 +199,33 @@ class EncryptionKeyManager {
try { try {
keyData = JSON.parse(encodedData); keyData = JSON.parse(encodedData);
} catch { } catch {
databaseLogger.warn('Found legacy base64-encoded key data, migrating', { databaseLogger.warn("Found legacy base64-encoded key data, migrating", {
operation: 'key_data_migration_legacy' operation: "key_data_migration_legacy",
}); });
keyData = JSON.parse(Buffer.from(encodedData, 'base64').toString()); keyData = JSON.parse(Buffer.from(encodedData, "base64").toString());
} }
this.keyInfo = { this.keyInfo = {
hasKey: true, hasKey: true,
keyId: keyData.keyId, keyId: keyData.keyId,
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
algorithm: keyData.algorithm algorithm: keyData.algorithm,
}; };
const decodedKey = this.decodeKey(keyData.key); const decodedKey = this.decodeKey(keyData.key);
if (!MasterKeyProtection.isProtectedKey(keyData.key)) { if (!MasterKeyProtection.isProtectedKey(keyData.key)) {
databaseLogger.info('Auto-migrating legacy key to KEK protection', { databaseLogger.info("Auto-migrating legacy key to KEK protection", {
operation: 'key_auto_migration', operation: "key_auto_migration",
keyId: keyData.keyId keyId: keyData.keyId,
}); });
await this.storeKey(decodedKey, keyData.keyId); await this.storeKey(decodedKey, keyData.keyId);
} }
return decodedKey; return decodedKey;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to retrieve stored encryption key', error, { databaseLogger.error("Failed to retrieve stored encryption key", error, {
operation: 'key_retrieve_failed' operation: "key_retrieve_failed",
}); });
return null; return null;
} }
@@ -221,28 +237,31 @@ class EncryptionKeyManager {
async getKeyInfo(): Promise<EncryptionKeyInfo> { async getKeyInfo(): Promise<EncryptionKeyInfo> {
if (!this.keyInfo) { if (!this.keyInfo) {
const hasKey = await this.getStoredKey() !== null; const hasKey = (await this.getStoredKey()) !== null;
return { return {
hasKey, hasKey,
algorithm: 'aes-256-gcm' algorithm: "aes-256-gcm",
}; };
} }
return this.keyInfo; return this.keyInfo;
} }
async regenerateKey(): Promise<string> { async regenerateKey(): Promise<string> {
databaseLogger.info('Regenerating encryption key', { databaseLogger.info("Regenerating encryption key", {
operation: 'key_regenerate' operation: "key_regenerate",
}); });
const oldKeyInfo = await this.getKeyInfo(); const oldKeyInfo = await this.getKeyInfo();
const newKey = await this.generateNewKey(); const newKey = await this.generateNewKey();
databaseLogger.warn('Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED', { databaseLogger.warn(
operation: 'key_regenerated', "Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED",
{
operation: "key_regenerated",
oldKeyId: oldKeyInfo.keyId, oldKeyId: oldKeyInfo.keyId,
newKeyId: this.keyInfo?.keyId newKeyId: this.keyInfo?.keyId,
}); },
);
return newKey; return newKey;
} }
@@ -257,7 +276,11 @@ class EncryptionKeyManager {
const entropyTest = new Set(key).size / key.length; const entropyTest = new Set(key).size / key.length;
const complexity = Number(hasLower) + Number(hasUpper) + Number(hasDigit) + Number(hasSpecial); const complexity =
Number(hasLower) +
Number(hasUpper) +
Number(hasDigit) +
Number(hasSpecial);
return complexity >= 3 && entropyTest > 0.4; return complexity >= 3 && entropyTest > 0.4;
} }
@@ -266,16 +289,20 @@ class EncryptionKeyManager {
if (!testKey) return false; if (!testKey) return false;
try { try {
const testData = 'validation-test-' + Date.now(); const testData = "validation-test-" + Date.now();
const testBuffer = Buffer.from(testKey, 'hex'); const testBuffer = Buffer.from(testKey, "hex");
if (testBuffer.length !== 32) { if (testBuffer.length !== 32) {
return false; return false;
} }
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', testBuffer, iv) as any; const cipher = crypto.createCipheriv(
cipher.update(testData, 'utf8'); "aes-256-gcm",
testBuffer,
iv,
) as any;
cipher.update(testData, "utf8");
cipher.final(); cipher.final();
cipher.getAuthTag(); cipher.getAuthTag();
@@ -302,13 +329,16 @@ class EncryptionKeyManager {
algorithm: keyInfo.algorithm, algorithm: keyInfo.algorithm,
initialized: this.isInitialized(), initialized: this.isInitialized(),
kekProtected, kekProtected,
kekValid: kekProtected ? MasterKeyProtection.validateProtection() : false kekValid: kekProtected ? MasterKeyProtection.validateProtection() : false,
}; };
} }
private async isKEKProtected(): Promise<boolean> { private async isKEKProtected(): Promise<boolean> {
try { try {
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); const result = await db
.select()
.from(settings)
.where(eq(settings.key, "db_encryption_key"));
if (result.length === 0) return false; if (result.length === 0) return false;
const keyData = JSON.parse(result[0].value); const keyData = JSON.parse(result[0].value);

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
import { DatabaseEncryption } from './database-encryption.js'; import { DatabaseEncryption } from "./database-encryption.js";
import { EncryptedDBOperations } from './encrypted-db-operations.js'; import { EncryptedDBOperations } from "./encrypted-db-operations.js";
import { EncryptionKeyManager } from './encryption-key-manager.js'; import { EncryptionKeyManager } from "./encryption-key-manager.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import { db } from '../database/db/index.js'; import { db } from "../database/db/index.js";
import { settings } from '../database/db/schema.js'; import { settings } from "../database/db/schema.js";
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from "drizzle-orm";
interface MigrationConfig { interface MigrationConfig {
masterPassword?: string; masterPassword?: string;
@@ -22,15 +22,15 @@ class EncryptionMigration {
masterPassword: config.masterPassword, masterPassword: config.masterPassword,
forceEncryption: config.forceEncryption ?? false, forceEncryption: config.forceEncryption ?? false,
backupEnabled: config.backupEnabled ?? true, backupEnabled: config.backupEnabled ?? true,
dryRun: config.dryRun ?? false dryRun: config.dryRun ?? false,
}; };
} }
async runMigration(): Promise<void> { async runMigration(): Promise<void> {
databaseLogger.info('Starting database encryption migration', { databaseLogger.info("Starting database encryption migration", {
operation: 'migration_start', operation: "migration_start",
dryRun: this.config.dryRun, dryRun: this.config.dryRun,
forceEncryption: this.config.forceEncryption forceEncryption: this.config.forceEncryption,
}); });
try { try {
@@ -45,21 +45,23 @@ class EncryptionMigration {
await this.updateSettings(); await this.updateSettings();
await this.verifyMigration(); await this.verifyMigration();
databaseLogger.success('Database encryption migration completed successfully', { databaseLogger.success(
operation: 'migration_complete' "Database encryption migration completed successfully",
}); {
operation: "migration_complete",
},
);
} catch (error) { } catch (error) {
databaseLogger.error('Migration failed', error, { databaseLogger.error("Migration failed", error, {
operation: 'migration_failed' operation: "migration_failed",
}); });
throw error; throw error;
} }
} }
private async validatePrerequisites(): Promise<void> { private async validatePrerequisites(): Promise<void> {
databaseLogger.info('Validating migration prerequisites', { databaseLogger.info("Validating migration prerequisites", {
operation: 'validation' operation: "validation",
}); });
// Check if KEK-managed encryption key exists // Check if KEK-managed encryption key exists
@@ -77,187 +79,200 @@ class EncryptionMigration {
this.config.masterPassword = currentKey; this.config.masterPassword = currentKey;
} }
} catch (error) { } catch (error) {
throw new Error('Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.'); throw new Error(
"Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.",
);
} }
} }
// Validate key strength // Validate key strength
if (this.config.masterPassword.length < 16) { if (this.config.masterPassword.length < 16) {
throw new Error('Master password must be at least 16 characters long'); throw new Error("Master password must be at least 16 characters long");
} }
// Test database connection // Test database connection
try { try {
await db.select().from(settings).limit(1); await db.select().from(settings).limit(1);
} catch (error) { } catch (error) {
throw new Error('Database connection failed'); throw new Error("Database connection failed");
} }
databaseLogger.success('Prerequisites validation passed', { databaseLogger.success("Prerequisites validation passed", {
operation: 'validation_complete', operation: "validation_complete",
keySource: 'kek_manager' keySource: "kek_manager",
}); });
} }
private async createBackup(): Promise<void> { private async createBackup(): Promise<void> {
databaseLogger.info('Creating database backup before migration', { databaseLogger.info("Creating database backup before migration", {
operation: 'backup_start' operation: "backup_start",
}); });
try { try {
const fs = await import('fs'); const fs = await import("fs");
const path = await import('path'); const path = await import("path");
const dataDir = process.env.DATA_DIR || './db/data'; const dataDir = process.env.DATA_DIR || "./db/data";
const dbPath = path.join(dataDir, 'db.sqlite'); const dbPath = path.join(dataDir, "db.sqlite");
const backupPath = path.join(dataDir, `db-backup-${Date.now()}.sqlite`); const backupPath = path.join(dataDir, `db-backup-${Date.now()}.sqlite`);
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
databaseLogger.success(`Database backup created: ${backupPath}`, { databaseLogger.success(`Database backup created: ${backupPath}`, {
operation: 'backup_complete', operation: "backup_complete",
backupPath backupPath,
}); });
} }
} catch (error) { } catch (error) {
databaseLogger.error('Failed to create backup', error, { databaseLogger.error("Failed to create backup", error, {
operation: 'backup_failed' operation: "backup_failed",
}); });
throw error; throw error;
} }
} }
private async initializeEncryption(): Promise<void> { private async initializeEncryption(): Promise<void> {
databaseLogger.info('Initializing encryption system', { databaseLogger.info("Initializing encryption system", {
operation: 'encryption_init' operation: "encryption_init",
}); });
DatabaseEncryption.initialize({ DatabaseEncryption.initialize({
masterPassword: this.config.masterPassword!, masterPassword: this.config.masterPassword!,
encryptionEnabled: true, encryptionEnabled: true,
forceEncryption: this.config.forceEncryption, forceEncryption: this.config.forceEncryption,
migrateOnAccess: true migrateOnAccess: true,
}); });
const isHealthy = await EncryptedDBOperations.healthCheck(); const isHealthy = await EncryptedDBOperations.healthCheck();
if (!isHealthy) { if (!isHealthy) {
throw new Error('Encryption system health check failed'); throw new Error("Encryption system health check failed");
} }
databaseLogger.success('Encryption system initialized successfully', { databaseLogger.success("Encryption system initialized successfully", {
operation: 'encryption_init_complete' operation: "encryption_init_complete",
}); });
} }
private async migrateTables(): Promise<void> { private async migrateTables(): Promise<void> {
const tables: Array<'users' | 'ssh_data' | 'ssh_credentials'> = [ const tables: Array<"users" | "ssh_data" | "ssh_credentials"> = [
'users', "users",
'ssh_data', "ssh_data",
'ssh_credentials' "ssh_credentials",
]; ];
let totalMigrated = 0; let totalMigrated = 0;
for (const tableName of tables) { for (const tableName of tables) {
databaseLogger.info(`Starting migration for table: ${tableName}`, { databaseLogger.info(`Starting migration for table: ${tableName}`, {
operation: 'table_migration_start', operation: "table_migration_start",
table: tableName table: tableName,
}); });
try { try {
if (this.config.dryRun) { if (this.config.dryRun) {
databaseLogger.info(`[DRY RUN] Would migrate table: ${tableName}`, { databaseLogger.info(`[DRY RUN] Would migrate table: ${tableName}`, {
operation: 'dry_run_table', operation: "dry_run_table",
table: tableName table: tableName,
}); });
continue; continue;
} }
const migratedCount = await EncryptedDBOperations.migrateExistingRecords(tableName); const migratedCount =
await EncryptedDBOperations.migrateExistingRecords(tableName);
totalMigrated += migratedCount; totalMigrated += migratedCount;
databaseLogger.success(`Migration completed for table: ${tableName}`, { databaseLogger.success(`Migration completed for table: ${tableName}`, {
operation: 'table_migration_complete', operation: "table_migration_complete",
table: tableName, table: tableName,
migratedCount migratedCount,
}); });
} catch (error) { } catch (error) {
databaseLogger.error(`Migration failed for table: ${tableName}`, error, { databaseLogger.error(
operation: 'table_migration_failed', `Migration failed for table: ${tableName}`,
table: tableName error,
}); {
operation: "table_migration_failed",
table: tableName,
},
);
throw error; throw error;
} }
} }
databaseLogger.success(`All tables migrated successfully`, { databaseLogger.success(`All tables migrated successfully`, {
operation: 'all_tables_migrated', operation: "all_tables_migrated",
totalMigrated totalMigrated,
}); });
} }
private async updateSettings(): Promise<void> { private async updateSettings(): Promise<void> {
if (this.config.dryRun) { if (this.config.dryRun) {
databaseLogger.info('[DRY RUN] Would update encryption settings', { databaseLogger.info("[DRY RUN] Would update encryption settings", {
operation: 'dry_run_settings' operation: "dry_run_settings",
}); });
return; return;
} }
try { try {
const encryptionSettings = [ const encryptionSettings = [
{ key: 'encryption_enabled', value: 'true' }, { key: "encryption_enabled", value: "true" },
{ key: 'encryption_migration_completed', value: new Date().toISOString() }, {
{ key: 'encryption_version', value: '1.0' } key: "encryption_migration_completed",
value: new Date().toISOString(),
},
{ key: "encryption_version", value: "1.0" },
]; ];
for (const setting of encryptionSettings) { for (const setting of encryptionSettings) {
const existing = await db.select().from(settings).where(eq(settings.key, setting.key)); const existing = await db
.select()
.from(settings)
.where(eq(settings.key, setting.key));
if (existing.length > 0) { if (existing.length > 0) {
await db.update(settings).set({ value: setting.value }).where(eq(settings.key, setting.key)); await db
.update(settings)
.set({ value: setting.value })
.where(eq(settings.key, setting.key));
} else { } else {
await db.insert(settings).values(setting); await db.insert(settings).values(setting);
} }
} }
databaseLogger.success('Encryption settings updated', { databaseLogger.success("Encryption settings updated", {
operation: 'settings_updated' operation: "settings_updated",
}); });
} catch (error) { } catch (error) {
databaseLogger.error('Failed to update settings', error, { databaseLogger.error("Failed to update settings", error, {
operation: 'settings_update_failed' operation: "settings_update_failed",
}); });
throw error; throw error;
} }
} }
private async verifyMigration(): Promise<void> { private async verifyMigration(): Promise<void> {
databaseLogger.info('Verifying migration integrity', { databaseLogger.info("Verifying migration integrity", {
operation: 'verification_start' operation: "verification_start",
}); });
try { try {
const status = DatabaseEncryption.getEncryptionStatus(); const status = DatabaseEncryption.getEncryptionStatus();
if (!status.enabled || !status.configValid) { if (!status.enabled || !status.configValid) {
throw new Error('Encryption system verification failed'); throw new Error("Encryption system verification failed");
} }
const testResult = await this.performTestEncryption(); const testResult = await this.performTestEncryption();
if (!testResult) { if (!testResult) {
throw new Error('Test encryption/decryption failed'); throw new Error("Test encryption/decryption failed");
} }
databaseLogger.success('Migration verification completed successfully', { databaseLogger.success("Migration verification completed successfully", {
operation: 'verification_complete', operation: "verification_complete",
status status,
}); });
} catch (error) { } catch (error) {
databaseLogger.error('Migration verification failed', error, { databaseLogger.error("Migration verification failed", error, {
operation: 'verification_failed' operation: "verification_failed",
}); });
throw error; throw error;
} }
@@ -265,9 +280,12 @@ class EncryptionMigration {
private async performTestEncryption(): Promise<boolean> { private async performTestEncryption(): Promise<boolean> {
try { try {
const { FieldEncryption } = await import('./encryption.js'); const { FieldEncryption } = await import("./encryption.js");
const testData = `test-data-${Date.now()}`; const testData = `test-data-${Date.now()}`;
const testKey = FieldEncryption.getFieldKey(this.config.masterPassword!, 'test'); const testKey = FieldEncryption.getFieldKey(
this.config.masterPassword!,
"test",
);
const encrypted = FieldEncryption.encryptField(testData, testKey); const encrypted = FieldEncryption.encryptField(testData, testKey);
const decrypted = FieldEncryption.decryptField(encrypted, testKey); const decrypted = FieldEncryption.decryptField(encrypted, testKey);
@@ -285,10 +303,17 @@ class EncryptionMigration {
migrationDate?: string; migrationDate?: string;
}> { }> {
try { try {
const encryptionEnabled = await db.select().from(settings).where(eq(settings.key, 'encryption_enabled')); const encryptionEnabled = await db
const migrationCompleted = await db.select().from(settings).where(eq(settings.key, 'encryption_migration_completed')); .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 isEncryptionEnabled =
encryptionEnabled.length > 0 && encryptionEnabled[0].value === "true";
const isMigrationCompleted = migrationCompleted.length > 0; const isMigrationCompleted = migrationCompleted.length > 0;
// Check if migration is actually required by looking for unencrypted sensitive data // Check if migration is actually required by looking for unencrypted sensitive data
@@ -298,11 +323,13 @@ class EncryptionMigration {
isEncryptionEnabled, isEncryptionEnabled,
migrationCompleted: isMigrationCompleted, migrationCompleted: isMigrationCompleted,
migrationRequired, migrationRequired,
migrationDate: isMigrationCompleted ? migrationCompleted[0].value : undefined migrationDate: isMigrationCompleted
? migrationCompleted[0].value
: undefined,
}; };
} catch (error) { } catch (error) {
databaseLogger.error('Failed to check migration status', error, { databaseLogger.error("Failed to check migration status", error, {
operation: 'status_check_failed' operation: "status_check_failed",
}); });
throw error; throw error;
} }
@@ -311,10 +338,14 @@ class EncryptionMigration {
static async checkIfMigrationRequired(): Promise<boolean> { static async checkIfMigrationRequired(): Promise<boolean> {
try { try {
// Import table schemas // Import table schemas
const { sshData, sshCredentials } = await import('../database/db/schema.js'); const { sshData, sshCredentials } = await import(
"../database/db/schema.js"
);
// Check if there's any unencrypted sensitive data in ssh_data // Check if there's any unencrypted sensitive data in ssh_data
const sshDataCount = await db.select({ count: sql<number>`count(*)` }).from(sshData); const sshDataCount = await db
.select({ count: sql<number>`count(*)` })
.from(sshData);
if (sshDataCount[0].count > 0) { if (sshDataCount[0].count > 0) {
// Sample a few records to check if they contain unencrypted data // Sample a few records to check if they contain unencrypted data
const sampleData = await db.select().from(sshData).limit(5); const sampleData = await db.select().from(sshData).limit(5);
@@ -329,9 +360,14 @@ class EncryptionMigration {
} }
// Check if there's any unencrypted sensitive data in ssh_credentials // Check if there's any unencrypted sensitive data in ssh_credentials
const credentialsCount = await db.select({ count: sql<number>`count(*)` }).from(sshCredentials); const credentialsCount = await db
.select({ count: sql<number>`count(*)` })
.from(sshCredentials);
if (credentialsCount[0].count > 0) { if (credentialsCount[0].count > 0) {
const sampleCredentials = await db.select().from(sshCredentials).limit(5); const sampleCredentials = await db
.select()
.from(sshCredentials)
.limit(5);
for (const record of sampleCredentials) { for (const record of sampleCredentials) {
if (record.password && !this.looksEncrypted(record.password)) { if (record.password && !this.looksEncrypted(record.password)) {
return true; // Found unencrypted password return true; // Found unencrypted password
@@ -347,10 +383,13 @@ class EncryptionMigration {
return false; // No unencrypted sensitive data found return false; // No unencrypted sensitive data found
} catch (error) { } catch (error) {
databaseLogger.warn('Failed to check if migration required, assuming required', { databaseLogger.warn(
operation: 'migration_check_failed', "Failed to check if migration required, assuming required",
error: error instanceof Error ? error.message : 'Unknown error' {
}); 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 return true; // If we can't check, assume migration is required for safety
} }
} }
@@ -365,7 +404,7 @@ class EncryptionMigration {
} catch { } catch {
// If it's not JSON, check if it's a reasonable length for encrypted data // If it's not JSON, check if it's a reasonable length for encrypted data
// Encrypted data is typically much longer than plaintext // Encrypted data is typically much longer than plaintext
return data.length > 100 && data.includes('='); // Base64-like characteristics return data.length > 100 && data.includes("="); // Base64-like characteristics
} }
} }
} }
@@ -373,20 +412,21 @@ class EncryptionMigration {
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
const config: MigrationConfig = { const config: MigrationConfig = {
masterPassword: process.env.DB_ENCRYPTION_KEY, masterPassword: process.env.DB_ENCRYPTION_KEY,
forceEncryption: process.env.FORCE_ENCRYPTION === 'true', forceEncryption: process.env.FORCE_ENCRYPTION === "true",
backupEnabled: process.env.BACKUP_ENABLED !== 'false', backupEnabled: process.env.BACKUP_ENABLED !== "false",
dryRun: process.env.DRY_RUN === 'true' dryRun: process.env.DRY_RUN === "true",
}; };
const migration = new EncryptionMigration(config); const migration = new EncryptionMigration(config);
migration.runMigration() migration
.runMigration()
.then(() => { .then(() => {
console.log('Migration completed successfully'); console.log("Migration completed successfully");
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch((error) => {
console.error('Migration failed:', error.message); console.error("Migration failed:", error.message);
process.exit(1); process.exit(1);
}); });
} }

View File

@@ -1,24 +1,39 @@
#!/usr/bin/env node #!/usr/bin/env node
import { FieldEncryption } from './encryption.js'; import { FieldEncryption } from "./encryption.js";
import { DatabaseEncryption } from './database-encryption.js'; import { DatabaseEncryption } from "./database-encryption.js";
import { EncryptedDBOperations } from './encrypted-db-operations.js'; import { EncryptedDBOperations } from "./encrypted-db-operations.js";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
class EncryptionTest { class EncryptionTest {
private testPassword = 'test-master-password-for-validation'; private testPassword = "test-master-password-for-validation";
async runAllTests(): Promise<boolean> { async runAllTests(): Promise<boolean> {
console.log('🔐 Starting Termix Database Encryption Tests...\n'); console.log("🔐 Starting Termix Database Encryption Tests...\n");
const tests = [ const tests = [
{ name: 'Basic Encryption/Decryption', test: () => this.testBasicEncryption() }, {
{ name: 'Field Encryption Detection', test: () => this.testFieldDetection() }, name: "Basic Encryption/Decryption",
{ name: 'Key Derivation', test: () => this.testKeyDerivation() }, test: () => this.testBasicEncryption(),
{ name: 'Database Encryption Context', test: () => this.testDatabaseContext() }, },
{ name: 'Record Encryption/Decryption', test: () => this.testRecordOperations() }, {
{ name: 'Backward Compatibility', test: () => this.testBackwardCompatibility() }, name: "Field Encryption Detection",
{ name: 'Error Handling', test: () => this.testErrorHandling() }, test: () => this.testFieldDetection(),
{ name: 'Performance Test', test: () => this.testPerformance() } },
{ 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 passedTests = 0;
@@ -32,7 +47,9 @@ class EncryptionTest {
passedTests++; passedTests++;
} catch (error) { } catch (error) {
console.log(`❌ FAILED: ${test.name}`); console.log(`❌ FAILED: ${test.name}`);
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`); console.log(
` Error: ${error instanceof Error ? error.message : "Unknown error"}\n`,
);
} }
} }
@@ -40,75 +57,85 @@ class EncryptionTest {
console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`); console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`);
if (success) { if (success) {
console.log('🎉 All encryption tests PASSED! System is ready for production.'); console.log(
"🎉 All encryption tests PASSED! System is ready for production.",
);
} else { } else {
console.log('⚠️ Some tests FAILED! Please review the implementation.'); console.log("⚠️ Some tests FAILED! Please review the implementation.");
} }
return success; return success;
} }
private async testBasicEncryption(): Promise<void> { private async testBasicEncryption(): Promise<void> {
const testData = 'Hello, World! This is sensitive data.'; const testData = "Hello, World! This is sensitive data.";
const key = FieldEncryption.getFieldKey(this.testPassword, 'test-field'); const key = FieldEncryption.getFieldKey(this.testPassword, "test-field");
const encrypted = FieldEncryption.encryptField(testData, key); const encrypted = FieldEncryption.encryptField(testData, key);
const decrypted = FieldEncryption.decryptField(encrypted, key); const decrypted = FieldEncryption.decryptField(encrypted, key);
if (decrypted !== testData) { if (decrypted !== testData) {
throw new Error(`Decryption mismatch: expected "${testData}", got "${decrypted}"`); throw new Error(
`Decryption mismatch: expected "${testData}", got "${decrypted}"`,
);
} }
if (!FieldEncryption.isEncrypted(encrypted)) { if (!FieldEncryption.isEncrypted(encrypted)) {
throw new Error('Encrypted data not detected as encrypted'); throw new Error("Encrypted data not detected as encrypted");
} }
if (FieldEncryption.isEncrypted(testData)) { if (FieldEncryption.isEncrypted(testData)) {
throw new Error('Plain text incorrectly detected as encrypted'); throw new Error("Plain text incorrectly detected as encrypted");
} }
} }
private async testFieldDetection(): Promise<void> { private async testFieldDetection(): Promise<void> {
const testCases = [ const testCases = [
{ table: 'users', field: 'password_hash', shouldEncrypt: true }, { table: "users", field: "password_hash", shouldEncrypt: true },
{ table: 'users', field: 'username', shouldEncrypt: false }, { table: "users", field: "username", shouldEncrypt: false },
{ table: 'ssh_data', field: 'password', shouldEncrypt: true }, { table: "ssh_data", field: "password", shouldEncrypt: true },
{ table: 'ssh_data', field: 'ip', shouldEncrypt: false }, { table: "ssh_data", field: "ip", shouldEncrypt: false },
{ table: 'ssh_credentials', field: 'privateKey', shouldEncrypt: true }, { table: "ssh_credentials", field: "privateKey", shouldEncrypt: true },
{ table: 'unknown_table', field: 'any_field', shouldEncrypt: false } { table: "unknown_table", field: "any_field", shouldEncrypt: false },
]; ];
for (const testCase of testCases) { for (const testCase of testCases) {
const result = FieldEncryption.shouldEncryptField(testCase.table, testCase.field); const result = FieldEncryption.shouldEncryptField(
testCase.table,
testCase.field,
);
if (result !== testCase.shouldEncrypt) { if (result !== testCase.shouldEncrypt) {
throw new Error( throw new Error(
`Field detection failed for ${testCase.table}.${testCase.field}: ` + `Field detection failed for ${testCase.table}.${testCase.field}: ` +
`expected ${testCase.shouldEncrypt}, got ${result}` `expected ${testCase.shouldEncrypt}, got ${result}`,
); );
} }
} }
} }
private async testKeyDerivation(): Promise<void> { private async testKeyDerivation(): Promise<void> {
const password = 'test-password'; const password = "test-password";
const fieldType1 = 'users.password_hash'; const fieldType1 = "users.password_hash";
const fieldType2 = 'ssh_data.password'; const fieldType2 = "ssh_data.password";
const key1a = FieldEncryption.getFieldKey(password, fieldType1); const key1a = FieldEncryption.getFieldKey(password, fieldType1);
const key1b = FieldEncryption.getFieldKey(password, fieldType1); const key1b = FieldEncryption.getFieldKey(password, fieldType1);
const key2 = FieldEncryption.getFieldKey(password, fieldType2); const key2 = FieldEncryption.getFieldKey(password, fieldType2);
if (!key1a.equals(key1b)) { if (!key1a.equals(key1b)) {
throw new Error('Same field type should produce identical keys'); throw new Error("Same field type should produce identical keys");
} }
if (key1a.equals(key2)) { if (key1a.equals(key2)) {
throw new Error('Different field types should produce different keys'); throw new Error("Different field types should produce different keys");
} }
const differentPasswordKey = FieldEncryption.getFieldKey('different-password', fieldType1); const differentPasswordKey = FieldEncryption.getFieldKey(
"different-password",
fieldType1,
);
if (key1a.equals(differentPasswordKey)) { if (key1a.equals(differentPasswordKey)) {
throw new Error('Different passwords should produce different keys'); throw new Error("Different passwords should produce different keys");
} }
} }
@@ -117,88 +144,101 @@ class EncryptionTest {
masterPassword: this.testPassword, masterPassword: this.testPassword,
encryptionEnabled: true, encryptionEnabled: true,
forceEncryption: false, forceEncryption: false,
migrateOnAccess: true migrateOnAccess: true,
}); });
const status = DatabaseEncryption.getEncryptionStatus(); const status = DatabaseEncryption.getEncryptionStatus();
if (!status.enabled) { if (!status.enabled) {
throw new Error('Encryption should be enabled'); throw new Error("Encryption should be enabled");
} }
if (!status.configValid) { if (!status.configValid) {
throw new Error('Configuration should be valid'); throw new Error("Configuration should be valid");
} }
} }
private async testRecordOperations(): Promise<void> { private async testRecordOperations(): Promise<void> {
const testRecord = { const testRecord = {
id: 'test-id-123', id: "test-id-123",
username: 'testuser', username: "testuser",
password_hash: 'sensitive-password-hash', password_hash: "sensitive-password-hash",
is_admin: false is_admin: false,
}; };
const encrypted = DatabaseEncryption.encryptRecord('users', testRecord); const encrypted = DatabaseEncryption.encryptRecord("users", testRecord);
const decrypted = DatabaseEncryption.decryptRecord('users', encrypted); const decrypted = DatabaseEncryption.decryptRecord("users", encrypted);
if (decrypted.username !== testRecord.username) { if (decrypted.username !== testRecord.username) {
throw new Error('Non-sensitive field should remain unchanged'); throw new Error("Non-sensitive field should remain unchanged");
} }
if (decrypted.password_hash !== testRecord.password_hash) { if (decrypted.password_hash !== testRecord.password_hash) {
throw new Error('Sensitive field should be properly decrypted'); throw new Error("Sensitive field should be properly decrypted");
} }
if (!FieldEncryption.isEncrypted(encrypted.password_hash)) { if (!FieldEncryption.isEncrypted(encrypted.password_hash)) {
throw new Error('Sensitive field should be encrypted in stored record'); throw new Error("Sensitive field should be encrypted in stored record");
} }
} }
private async testBackwardCompatibility(): Promise<void> { private async testBackwardCompatibility(): Promise<void> {
const plaintextRecord = { const plaintextRecord = {
id: 'legacy-id-456', id: "legacy-id-456",
username: 'legacyuser', username: "legacyuser",
password_hash: 'plain-text-password-hash', password_hash: "plain-text-password-hash",
is_admin: false is_admin: false,
}; };
const decrypted = DatabaseEncryption.decryptRecord('users', plaintextRecord); const decrypted = DatabaseEncryption.decryptRecord(
"users",
plaintextRecord,
);
if (decrypted.password_hash !== plaintextRecord.password_hash) { if (decrypted.password_hash !== plaintextRecord.password_hash) {
throw new Error('Plain text fields should be returned as-is for backward compatibility'); throw new Error(
"Plain text fields should be returned as-is for backward compatibility",
);
} }
if (decrypted.username !== plaintextRecord.username) { if (decrypted.username !== plaintextRecord.username) {
throw new Error('Non-sensitive fields should be unchanged'); throw new Error("Non-sensitive fields should be unchanged");
} }
} }
private async testErrorHandling(): Promise<void> { private async testErrorHandling(): Promise<void> {
const key = FieldEncryption.getFieldKey(this.testPassword, 'test'); const key = FieldEncryption.getFieldKey(this.testPassword, "test");
try { try {
FieldEncryption.decryptField('invalid-json-data', key); FieldEncryption.decryptField("invalid-json-data", key);
throw new Error('Should have thrown error for invalid JSON'); throw new Error("Should have thrown error for invalid JSON");
} catch (error) { } catch (error) {
if (!error || !(error as Error).message.includes('decryption failed')) { if (!error || !(error as Error).message.includes("decryption failed")) {
throw new Error('Should throw appropriate decryption error'); throw new Error("Should throw appropriate decryption error");
} }
} }
try { try {
const fakeEncrypted = JSON.stringify({ data: 'fake', iv: 'fake', tag: 'fake' }); const fakeEncrypted = JSON.stringify({
data: "fake",
iv: "fake",
tag: "fake",
});
FieldEncryption.decryptField(fakeEncrypted, key); FieldEncryption.decryptField(fakeEncrypted, key);
throw new Error('Should have thrown error for invalid encrypted data'); throw new Error("Should have thrown error for invalid encrypted data");
} catch (error) { } catch (error) {
if (!error || !(error as Error).message.includes('Decryption failed')) { if (!error || !(error as Error).message.includes("Decryption failed")) {
throw new Error('Should throw appropriate error for corrupted data'); throw new Error("Should throw appropriate error for corrupted data");
} }
} }
} }
private async testPerformance(): Promise<void> { private async testPerformance(): Promise<void> {
const testData = 'Performance test data that is reasonably long to simulate real SSH keys and passwords.'; const testData =
const key = FieldEncryption.getFieldKey(this.testPassword, 'performance-test'); "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 iterations = 100;
const startTime = Date.now(); const startTime = Date.now();
@@ -216,50 +256,57 @@ class EncryptionTest {
const totalTime = endTime - startTime; const totalTime = endTime - startTime;
const avgTime = totalTime / iterations; const avgTime = totalTime / iterations;
console.log(` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`); console.log(
` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`,
);
if (avgTime > 50) { if (avgTime > 50) {
console.log(' ⚠️ Warning: Encryption operations are slower than expected'); console.log(
" ⚠️ Warning: Encryption operations are slower than expected",
);
} }
} }
static async validateProduction(): Promise<boolean> { static async validateProduction(): Promise<boolean> {
console.log('🔒 Validating production encryption setup...\n'); console.log("🔒 Validating production encryption setup...\n");
try { try {
const encryptionKey = process.env.DB_ENCRYPTION_KEY; const encryptionKey = process.env.DB_ENCRYPTION_KEY;
if (!encryptionKey) { if (!encryptionKey) {
console.log('❌ DB_ENCRYPTION_KEY environment variable not set'); console.log("❌ DB_ENCRYPTION_KEY environment variable not set");
return false; return false;
} }
if (encryptionKey === 'default-key-change-me') { if (encryptionKey === "default-key-change-me") {
console.log('❌ DB_ENCRYPTION_KEY is using default value (INSECURE)'); console.log("❌ DB_ENCRYPTION_KEY is using default value (INSECURE)");
return false; return false;
} }
if (encryptionKey.length < 16) { if (encryptionKey.length < 16) {
console.log('❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)'); console.log(
"❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)",
);
return false; return false;
} }
DatabaseEncryption.initialize({ DatabaseEncryption.initialize({
masterPassword: encryptionKey, masterPassword: encryptionKey,
encryptionEnabled: true encryptionEnabled: true,
}); });
const status = DatabaseEncryption.getEncryptionStatus(); const status = DatabaseEncryption.getEncryptionStatus();
if (!status.configValid) { if (!status.configValid) {
console.log('❌ Encryption configuration validation failed'); console.log("❌ Encryption configuration validation failed");
return false; return false;
} }
console.log('✅ Production encryption setup is valid'); console.log("✅ Production encryption setup is valid");
return true; return true;
} catch (error) { } catch (error) {
console.log(`❌ Production validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); console.log(
`❌ Production validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return false; return false;
} }
} }
@@ -268,23 +315,24 @@ class EncryptionTest {
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
const testMode = process.argv[2]; const testMode = process.argv[2];
if (testMode === 'production') { if (testMode === "production") {
EncryptionTest.validateProduction() EncryptionTest.validateProduction()
.then((success) => { .then((success) => {
process.exit(success ? 0 : 1); process.exit(success ? 0 : 1);
}) })
.catch((error) => { .catch((error) => {
console.error('Test execution failed:', error); console.error("Test execution failed:", error);
process.exit(1); process.exit(1);
}); });
} else { } else {
const test = new EncryptionTest(); const test = new EncryptionTest();
test.runAllTests() test
.runAllTests()
.then((success) => { .then((success) => {
process.exit(success ? 0 : 1); process.exit(success ? 0 : 1);
}) })
.catch((error) => { .catch((error) => {
console.error('Test execution failed:', error); console.error("Test execution failed:", error);
process.exit(1); process.exit(1);
}); });
} }

View File

@@ -1,4 +1,4 @@
import crypto from 'crypto'; import crypto from "crypto";
interface EncryptedData { interface EncryptedData {
data: string; data: string;
@@ -17,7 +17,7 @@ interface EncryptionConfig {
class FieldEncryption { class FieldEncryption {
private static readonly CONFIG: EncryptionConfig = { private static readonly CONFIG: EncryptionConfig = {
algorithm: 'aes-256-gcm', algorithm: "aes-256-gcm",
keyLength: 32, keyLength: 32,
ivLength: 16, ivLength: 16,
saltLength: 32, saltLength: 32,
@@ -25,9 +25,21 @@ class FieldEncryption {
}; };
private static readonly ENCRYPTED_FIELDS = { private static readonly ENCRYPTED_FIELDS = {
users: ['password_hash', 'client_secret', 'totp_secret', 'totp_backup_codes', 'oidc_identifier'], users: [
ssh_data: ['password', 'key', 'keyPassword'], "password_hash",
ssh_credentials: ['password', 'privateKey', 'keyPassword', 'key', 'publicKey'] "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 { static isEncrypted(value: string | null): boolean {
@@ -46,56 +58,64 @@ class FieldEncryption {
salt, salt,
this.CONFIG.iterations, this.CONFIG.iterations,
this.CONFIG.keyLength, this.CONFIG.keyLength,
'sha256' "sha256",
); );
return Buffer.from(crypto.hkdfSync( return Buffer.from(
'sha256', crypto.hkdfSync(
"sha256",
masterKey, masterKey,
salt, salt,
keyType, keyType,
this.CONFIG.keyLength this.CONFIG.keyLength,
)); ),
);
} }
static encrypt(plaintext: string, key: Buffer): EncryptedData { static encrypt(plaintext: string, key: Buffer): EncryptedData {
if (!plaintext) return { data: '', iv: '', tag: '' }; if (!plaintext) return { data: "", iv: "", tag: "" };
const iv = crypto.randomBytes(this.CONFIG.ivLength); const iv = crypto.randomBytes(this.CONFIG.ivLength);
const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any; const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any;
cipher.setAAD(Buffer.from('termix-field-encryption')); cipher.setAAD(Buffer.from("termix-field-encryption"));
let encrypted = cipher.update(plaintext, 'utf8', 'hex'); let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final('hex'); encrypted += cipher.final("hex");
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
return { return {
data: encrypted, data: encrypted,
iv: iv.toString('hex'), iv: iv.toString("hex"),
tag: tag.toString('hex') tag: tag.toString("hex"),
}; };
} }
static decrypt(encryptedData: EncryptedData, key: Buffer): string { static decrypt(encryptedData: EncryptedData, key: Buffer): string {
if (!encryptedData.data) return ''; if (!encryptedData.data) return "";
try { try {
const decipher = crypto.createDecipheriv(this.CONFIG.algorithm, key, Buffer.from(encryptedData.iv, 'hex')) as any; const decipher = crypto.createDecipheriv(
decipher.setAAD(Buffer.from('termix-field-encryption')); this.CONFIG.algorithm,
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex')); 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'); let decrypted = decipher.update(encryptedData.data, "hex", "utf8");
decrypted += decipher.final('utf8'); decrypted += decipher.final("utf8");
return decrypted; return decrypted;
} catch (error) { } catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
static encryptField(value: string, fieldKey: Buffer): string { static encryptField(value: string, fieldKey: Buffer): string {
if (!value) return ''; if (!value) return "";
if (this.isEncrypted(value)) return value; if (this.isEncrypted(value)) return value;
const encrypted = this.encrypt(value, fieldKey); const encrypted = this.encrypt(value, fieldKey);
@@ -103,36 +123,45 @@ class FieldEncryption {
} }
static decryptField(value: string, fieldKey: Buffer): string { static decryptField(value: string, fieldKey: Buffer): string {
if (!value) return ''; if (!value) return "";
if (!this.isEncrypted(value)) return value; if (!this.isEncrypted(value)) return value;
try { try {
const encrypted: EncryptedData = JSON.parse(value); const encrypted: EncryptedData = JSON.parse(value);
return this.decrypt(encrypted, fieldKey); return this.decrypt(encrypted, fieldKey);
} catch (error) { } catch (error) {
throw new Error(`Field decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Field decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
static getFieldKey(masterPassword: string, fieldType: string): Buffer { static getFieldKey(masterPassword: string, fieldType: string): Buffer {
const salt = crypto.createHash('sha256').update(`termix-${fieldType}`).digest(); const salt = crypto
.createHash("sha256")
.update(`termix-${fieldType}`)
.digest();
return this.deriveKey(masterPassword, salt, fieldType); return this.deriveKey(masterPassword, salt, fieldType);
} }
static shouldEncryptField(tableName: string, fieldName: string): boolean { static shouldEncryptField(tableName: string, fieldName: string): boolean {
const tableFields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS]; const tableFields =
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
return tableFields ? tableFields.includes(fieldName) : false; return tableFields ? tableFields.includes(fieldName) : false;
} }
static generateSalt(): string { static generateSalt(): string {
return crypto.randomBytes(this.CONFIG.saltLength).toString('hex'); return crypto.randomBytes(this.CONFIG.saltLength).toString("hex");
} }
static validateEncryptionHealth(encryptedValue: string, key: Buffer): boolean { static validateEncryptionHealth(
encryptedValue: string,
key: Buffer,
): boolean {
try { try {
if (!this.isEncrypted(encryptedValue)) return false; if (!this.isEncrypted(encryptedValue)) return false;
const decrypted = this.decryptField(encryptedValue, key); const decrypted = this.decryptField(encryptedValue, key);
return decrypted !== ''; return decrypted !== "";
} catch { } catch {
return false; return false;
} }

View File

@@ -1,8 +1,8 @@
import crypto from 'crypto'; import crypto from "crypto";
import os from 'os'; import os from "os";
import { execSync } from 'child_process'; import { execSync } from "child_process";
import fs from 'fs'; import fs from "fs";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
interface HardwareInfo { interface HardwareInfo {
cpuId?: string; cpuId?: string;
@@ -18,7 +18,7 @@ interface HardwareInfo {
* 相比软件环境指纹,硬件指纹在虚拟化和容器环境中更加稳定 * 相比软件环境指纹,硬件指纹在虚拟化和容器环境中更加稳定
*/ */
class HardwareFingerprint { class HardwareFingerprint {
private static readonly CACHE_KEY = 'cached_hardware_fingerprint'; private static readonly CACHE_KEY = "cached_hardware_fingerprint";
private static cachedFingerprint: string | null = null; private static cachedFingerprint: string | null = null;
/** /**
@@ -27,40 +27,30 @@ class HardwareFingerprint {
*/ */
static generate(): string { static generate(): string {
try { try {
// 1. 检查缓存
if (this.cachedFingerprint) { if (this.cachedFingerprint) {
return this.cachedFingerprint; return this.cachedFingerprint;
} }
// 2. 检查环境变量覆盖
const envFingerprint = process.env.TERMIX_HARDWARE_SEED; const envFingerprint = process.env.TERMIX_HARDWARE_SEED;
if (envFingerprint && envFingerprint.length >= 32) { if (envFingerprint && envFingerprint.length >= 32) {
databaseLogger.info('Using hardware seed from environment variable', { databaseLogger.info("Using hardware seed from environment variable", {
operation: 'hardware_fingerprint_env' operation: "hardware_fingerprint_env",
}); });
this.cachedFingerprint = this.hashFingerprint(envFingerprint); this.cachedFingerprint = this.hashFingerprint(envFingerprint);
return this.cachedFingerprint; return this.cachedFingerprint;
} }
// 3. 检测真实硬件信息
const hwInfo = this.detectHardwareInfo(); const hwInfo = this.detectHardwareInfo();
const fingerprint = this.generateFromHardware(hwInfo); const fingerprint = this.generateFromHardware(hwInfo);
this.cachedFingerprint = fingerprint; 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; return fingerprint;
} catch (error) { } catch (error) {
databaseLogger.error('Hardware fingerprint generation failed', error, { databaseLogger.error("Hardware fingerprint generation failed", error, {
operation: 'hardware_fingerprint_failed' operation: "hardware_fingerprint_failed",
}); });
// 回退到基本的环境指纹
return this.generateFallbackFingerprint(); return this.generateFallbackFingerprint();
} }
} }
@@ -74,21 +64,21 @@ class HardwareFingerprint {
try { try {
switch (platform) { switch (platform) {
case 'linux': case "linux":
hwInfo.cpuId = this.getLinuxCpuId(); hwInfo.cpuId = this.getLinuxCpuId();
hwInfo.motherboardUuid = this.getLinuxMotherboardUuid(); hwInfo.motherboardUuid = this.getLinuxMotherboardUuid();
hwInfo.diskSerial = this.getLinuxDiskSerial(); hwInfo.diskSerial = this.getLinuxDiskSerial();
hwInfo.biosSerial = this.getLinuxBiosSerial(); hwInfo.biosSerial = this.getLinuxBiosSerial();
break; break;
case 'win32': case "win32":
hwInfo.cpuId = this.getWindowsCpuId(); hwInfo.cpuId = this.getWindowsCpuId();
hwInfo.motherboardUuid = this.getWindowsMotherboardUuid(); hwInfo.motherboardUuid = this.getWindowsMotherboardUuid();
hwInfo.diskSerial = this.getWindowsDiskSerial(); hwInfo.diskSerial = this.getWindowsDiskSerial();
hwInfo.biosSerial = this.getWindowsBiosSerial(); hwInfo.biosSerial = this.getWindowsBiosSerial();
break; break;
case 'darwin': case "darwin":
hwInfo.cpuId = this.getMacOSCpuId(); hwInfo.cpuId = this.getMacOSCpuId();
hwInfo.motherboardUuid = this.getMacOSMotherboardUuid(); hwInfo.motherboardUuid = this.getMacOSMotherboardUuid();
hwInfo.diskSerial = this.getMacOSDiskSerial(); hwInfo.diskSerial = this.getMacOSDiskSerial();
@@ -98,11 +88,10 @@ class HardwareFingerprint {
// 所有平台都尝试获取MAC地址 // 所有平台都尝试获取MAC地址
hwInfo.macAddresses = this.getStableMacAddresses(); hwInfo.macAddresses = this.getStableMacAddresses();
} catch (error) { } catch (error) {
databaseLogger.error('Some hardware detection failed', error, { databaseLogger.error("Some hardware detection failed", error, {
operation: 'hardware_detection_partial_failure', operation: "hardware_detection_partial_failure",
platform platform,
}); });
} }
@@ -116,18 +105,32 @@ class HardwareFingerprint {
try { try {
// 尝试多种方法获取CPU信息 // 尝试多种方法获取CPU信息
const methods = [ const methods = [
() => fs.readFileSync('/proc/cpuinfo', 'utf8').match(/processor\s*:\s*(\d+)/)?.[1], () =>
() => execSync('dmidecode -t processor | grep "ID:" | head -1', { encoding: 'utf8' }).trim(), fs
() => execSync('cat /proc/cpuinfo | grep "cpu family\\|model\\|stepping" | md5sum', { encoding: 'utf8' }).split(' ')[0] .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) { for (const method of methods) {
try { try {
const result = method(); const result = method();
if (result && result.length > 0) return result; if (result && result.length > 0) return result;
} catch { /* 继续尝试下一种方法 */ } } catch {
/* 继续尝试下一种方法 */
}
}
} catch {
/* 忽略错误 */
} }
} catch { /* 忽略错误 */ }
return undefined; return undefined;
} }
@@ -135,47 +138,68 @@ class HardwareFingerprint {
try { try {
// 尝试多种方法获取主板UUID // 尝试多种方法获取主板UUID
const methods = [ const methods = [
() => fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(), () => fs.readFileSync("/sys/class/dmi/id/product_uuid", "utf8").trim(),
() => fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), () => fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
() => execSync('dmidecode -s system-uuid', { encoding: 'utf8' }).trim() () => execSync("dmidecode -s system-uuid", { encoding: "utf8" }).trim(),
]; ];
for (const method of methods) { for (const method of methods) {
try { try {
const result = method(); const result = method();
if (result && result.length > 0 && result !== 'Not Settable') return result; if (result && result.length > 0 && result !== "Not Settable")
} catch { /* 继续尝试下一种方法 */ } return result;
} catch {
/* 继续尝试下一种方法 */
}
}
} catch {
/* 忽略错误 */
} }
} catch { /* 忽略错误 */ }
return undefined; return undefined;
} }
private static getLinuxDiskSerial(): string | undefined { private static getLinuxDiskSerial(): string | undefined {
try { try {
// 获取根分区所在磁盘的序列号 // 获取根分区所在磁盘的序列号
const rootDisk = execSync("df / | tail -1 | awk '{print $1}' | sed 's/[0-9]*$//'", { encoding: 'utf8' }).trim(); const rootDisk = execSync(
"df / | tail -1 | awk '{print $1}' | sed 's/[0-9]*$//'",
{ encoding: "utf8" },
).trim();
if (rootDisk) { if (rootDisk) {
const serial = execSync(`udevadm info --name=${rootDisk} | grep ID_SERIAL= | cut -d= -f2`, { encoding: 'utf8' }).trim(); const serial = execSync(
`udevadm info --name=${rootDisk} | grep ID_SERIAL= | cut -d= -f2`,
{ encoding: "utf8" },
).trim();
if (serial && serial.length > 0) return serial; if (serial && serial.length > 0) return serial;
} }
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getLinuxBiosSerial(): string | undefined { private static getLinuxBiosSerial(): string | undefined {
try { try {
const methods = [ const methods = [
() => fs.readFileSync('/sys/class/dmi/id/board_serial', 'utf8').trim(), () => fs.readFileSync("/sys/class/dmi/id/board_serial", "utf8").trim(),
() => execSync('dmidecode -s baseboard-serial-number', { encoding: 'utf8' }).trim() () =>
execSync("dmidecode -s baseboard-serial-number", {
encoding: "utf8",
}).trim(),
]; ];
for (const method of methods) { for (const method of methods) {
try { try {
const result = method(); const result = method();
if (result && result.length > 0 && result !== 'Not Specified') return result; if (result && result.length > 0 && result !== "Not Specified")
} catch { /* 继续尝试下一种方法 */ } return result;
} catch {
/* 继续尝试下一种方法 */
}
}
} catch {
/* 忽略错误 */
} }
} catch { /* 忽略错误 */ }
return undefined; return undefined;
} }
@@ -184,37 +208,53 @@ class HardwareFingerprint {
*/ */
private static getWindowsCpuId(): string | undefined { private static getWindowsCpuId(): string | undefined {
try { try {
const result = execSync('wmic cpu get ProcessorId /value', { encoding: 'utf8' }); const result = execSync("wmic cpu get ProcessorId /value", {
encoding: "utf8",
});
const match = result.match(/ProcessorId=(.+)/); const match = result.match(/ProcessorId=(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getWindowsMotherboardUuid(): string | undefined { private static getWindowsMotherboardUuid(): string | undefined {
try { try {
const result = execSync('wmic csproduct get UUID /value', { encoding: 'utf8' }); const result = execSync("wmic csproduct get UUID /value", {
encoding: "utf8",
});
const match = result.match(/UUID=(.+)/); const match = result.match(/UUID=(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getWindowsDiskSerial(): string | undefined { private static getWindowsDiskSerial(): string | undefined {
try { try {
const result = execSync('wmic diskdrive get SerialNumber /value', { encoding: 'utf8' }); const result = execSync("wmic diskdrive get SerialNumber /value", {
encoding: "utf8",
});
const match = result.match(/SerialNumber=(.+)/); const match = result.match(/SerialNumber=(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getWindowsBiosSerial(): string | undefined { private static getWindowsBiosSerial(): string | undefined {
try { try {
const result = execSync('wmic baseboard get SerialNumber /value', { encoding: 'utf8' }); const result = execSync("wmic baseboard get SerialNumber /value", {
encoding: "utf8",
});
const match = result.match(/SerialNumber=(.+)/); const match = result.match(/SerialNumber=(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
@@ -223,36 +263,55 @@ class HardwareFingerprint {
*/ */
private static getMacOSCpuId(): string | undefined { private static getMacOSCpuId(): string | undefined {
try { try {
const result = execSync('sysctl -n machdep.cpu.brand_string', { encoding: 'utf8' }); const result = execSync("sysctl -n machdep.cpu.brand_string", {
encoding: "utf8",
});
return result.trim(); return result.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getMacOSMotherboardUuid(): string | undefined { private static getMacOSMotherboardUuid(): string | undefined {
try { try {
const result = execSync('system_profiler SPHardwareDataType | grep "Hardware UUID"', { encoding: 'utf8' }); const result = execSync(
'system_profiler SPHardwareDataType | grep "Hardware UUID"',
{ encoding: "utf8" },
);
const match = result.match(/Hardware UUID:\s*(.+)/); const match = result.match(/Hardware UUID:\s*(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getMacOSDiskSerial(): string | undefined { private static getMacOSDiskSerial(): string | undefined {
try { try {
const result = execSync('system_profiler SPStorageDataType | grep "Serial Number"', { encoding: 'utf8' }); const result = execSync(
'system_profiler SPStorageDataType | grep "Serial Number"',
{ encoding: "utf8" },
);
const match = result.match(/Serial Number:\s*(.+)/); const match = result.match(/Serial Number:\s*(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
private static getMacOSBiosSerial(): string | undefined { private static getMacOSBiosSerial(): string | undefined {
try { try {
const result = execSync('system_profiler SPHardwareDataType | grep "Serial Number"', { encoding: 'utf8' }); const result = execSync(
'system_profiler SPHardwareDataType | grep "Serial Number"',
{ encoding: "utf8" },
);
const match = result.match(/Serial Number \(system\):\s*(.+)/); const match = result.match(/Serial Number \(system\):\s*(.+)/);
return match?.[1]?.trim(); return match?.[1]?.trim();
} catch { /* 忽略错误 */ } } catch {
/* 忽略错误 */
}
return undefined; return undefined;
} }
@@ -265,17 +324,22 @@ class HardwareFingerprint {
const networkInterfaces = os.networkInterfaces(); const networkInterfaces = os.networkInterfaces();
const macAddresses: string[] = []; const macAddresses: string[] = [];
for (const [interfaceName, interfaces] of Object.entries(networkInterfaces)) { for (const [interfaceName, interfaces] of Object.entries(
networkInterfaces,
)) {
if (!interfaces) continue; if (!interfaces) continue;
// 排除虚拟接口和Docker接口 // 排除虚拟接口和Docker接口
if (interfaceName.match(/^(lo|docker|veth|br-|virbr)/)) continue; if (interfaceName.match(/^(lo|docker|veth|br-|virbr)/)) continue;
for (const iface of interfaces) { for (const iface of interfaces) {
if (!iface.internal && if (
!iface.internal &&
iface.mac && iface.mac &&
iface.mac !== '00:00:00:00:00:00' && iface.mac !== "00:00:00:00:00:00" &&
!iface.mac.startsWith('02:42:')) { // Docker接口特征 !iface.mac.startsWith("02:42:")
) {
// Docker接口特征
macAddresses.push(iface.mac); macAddresses.push(iface.mac);
} }
} }
@@ -296,16 +360,16 @@ class HardwareFingerprint {
hwInfo.cpuId, hwInfo.cpuId,
hwInfo.biosSerial, hwInfo.biosSerial,
hwInfo.diskSerial, hwInfo.diskSerial,
hwInfo.macAddresses?.join(','), hwInfo.macAddresses?.join(","),
os.platform(), // 操作系统平台 os.platform(), // 操作系统平台
os.arch() // CPU架构 os.arch(), // CPU架构
].filter(Boolean); // 过滤空值 ].filter(Boolean); // 过滤空值
if (components.length === 0) { if (components.length === 0) {
throw new Error('No hardware identifiers found'); throw new Error("No hardware identifiers found");
} }
return this.hashFingerprint(components.join('|')); return this.hashFingerprint(components.join("|"));
} }
/** /**
@@ -317,21 +381,24 @@ class HardwareFingerprint {
os.platform(), os.platform(),
os.arch(), os.arch(),
process.cwd(), process.cwd(),
'fallback-mode' "fallback-mode",
]; ];
databaseLogger.warn('Using fallback fingerprint due to hardware detection failure', { databaseLogger.warn(
operation: 'hardware_fingerprint_fallback' "Using fallback fingerprint due to hardware detection failure",
}); {
operation: "hardware_fingerprint_fallback",
},
);
return this.hashFingerprint(fallbackComponents.join('|')); return this.hashFingerprint(fallbackComponents.join("|"));
} }
/** /**
* 标准化指纹哈希 * 标准化指纹哈希
*/ */
private static hashFingerprint(data: string): string { private static hashFingerprint(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex'); return crypto.createHash("sha256").update(data).digest("hex");
} }
/** /**
@@ -341,7 +408,7 @@ class HardwareFingerprint {
const hwInfo = this.detectHardwareInfo(); const hwInfo = this.detectHardwareInfo();
return { return {
...hwInfo, ...hwInfo,
fingerprint: this.generate().substring(0, 16) fingerprint: this.generate().substring(0, 16),
}; };
} }

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto'; import crypto from "crypto";
import { databaseLogger } from './logger.js'; import { databaseLogger } from "./logger.js";
import { HardwareFingerprint } from './hardware-fingerprint.js'; import { HardwareFingerprint } from "./hardware-fingerprint.js";
interface ProtectedKeyData { interface ProtectedKeyData {
data: string; data: string;
@@ -11,30 +11,23 @@ interface ProtectedKeyData {
} }
class MasterKeyProtection { class MasterKeyProtection {
private static readonly VERSION = 'v1'; private static readonly VERSION = "v1";
private static readonly KEK_SALT = 'termix-kek-salt-v1'; private static readonly KEK_SALT = "termix-kek-salt-v1";
private static readonly KEK_ITERATIONS = 50000; private static readonly KEK_ITERATIONS = 50000;
private static generateDeviceFingerprint(): string { private static generateDeviceFingerprint(): string {
try { try {
const fingerprint = HardwareFingerprint.generate(); const fingerprint = HardwareFingerprint.generate();
databaseLogger.debug('Generated hardware fingerprint', {
operation: 'hardware_fingerprint_generation',
fingerprintPrefix: fingerprint.substring(0, 8)
});
return fingerprint; return fingerprint;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to generate hardware fingerprint', error, { databaseLogger.error("Failed to generate hardware fingerprint", error, {
operation: 'hardware_fingerprint_generation_failed' operation: "hardware_fingerprint_generation_failed",
}); });
throw new Error('Hardware fingerprint generation failed'); throw new Error("Hardware fingerprint generation failed");
} }
} }
private static deriveKEK(): Buffer { private static deriveKEK(): Buffer {
const fingerprint = this.generateDeviceFingerprint(); const fingerprint = this.generateDeviceFingerprint();
const salt = Buffer.from(this.KEK_SALT); const salt = Buffer.from(this.KEK_SALT);
@@ -44,103 +37,112 @@ class MasterKeyProtection {
salt, salt,
this.KEK_ITERATIONS, this.KEK_ITERATIONS,
32, 32,
'sha256' "sha256",
); );
databaseLogger.debug('Derived KEK from hardware fingerprint', {
operation: 'kek_derivation',
iterations: this.KEK_ITERATIONS
});
return kek; return kek;
} }
static encryptMasterKey(masterKey: string): string { static encryptMasterKey(masterKey: string): string {
if (!masterKey) { if (!masterKey) {
throw new Error('Master key cannot be empty'); throw new Error("Master key cannot be empty");
} }
try { try {
const kek = this.deriveKEK(); const kek = this.deriveKEK();
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv) as any; const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv) as any;
let encrypted = cipher.update(masterKey, 'hex', 'hex'); let encrypted = cipher.update(masterKey, "hex", "hex");
encrypted += cipher.final('hex'); encrypted += cipher.final("hex");
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
const protectedData: ProtectedKeyData = { const protectedData: ProtectedKeyData = {
data: encrypted, data: encrypted,
iv: iv.toString('hex'), iv: iv.toString("hex"),
tag: tag.toString('hex'), tag: tag.toString("hex"),
version: this.VERSION, version: this.VERSION,
fingerprint: this.generateDeviceFingerprint().substring(0, 16) fingerprint: this.generateDeviceFingerprint().substring(0, 16),
}; };
const result = JSON.stringify(protectedData); const result = JSON.stringify(protectedData);
databaseLogger.info('Master key encrypted with hardware KEK', { databaseLogger.info("Master key encrypted with hardware KEK", {
operation: 'master_key_encryption', operation: "master_key_encryption",
version: this.VERSION, version: this.VERSION,
fingerprintPrefix: protectedData.fingerprint fingerprintPrefix: protectedData.fingerprint,
}); });
return result; return result;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to encrypt master key', error, { databaseLogger.error("Failed to encrypt master key", error, {
operation: 'master_key_encryption_failed' operation: "master_key_encryption_failed",
}); });
throw new Error('Master key encryption failed'); throw new Error("Master key encryption failed");
} }
} }
static decryptMasterKey(encryptedKey: string): string { static decryptMasterKey(encryptedKey: string): string {
if (!encryptedKey) { if (!encryptedKey) {
throw new Error('Encrypted key cannot be empty'); throw new Error("Encrypted key cannot be empty");
} }
try { try {
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey); const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
if (protectedData.version !== this.VERSION) { if (protectedData.version !== this.VERSION) {
throw new Error(`Unsupported protection version: ${protectedData.version}`); throw new Error(
`Unsupported protection version: ${protectedData.version}`,
);
} }
const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16); const currentFingerprint = this.generateDeviceFingerprint().substring(
0,
16,
);
if (protectedData.fingerprint !== currentFingerprint) { if (protectedData.fingerprint !== currentFingerprint) {
databaseLogger.warn('Hardware fingerprint mismatch detected', { databaseLogger.warn("Hardware fingerprint mismatch detected", {
operation: 'master_key_decryption', operation: "master_key_decryption",
expected: protectedData.fingerprint, expected: protectedData.fingerprint,
current: currentFingerprint current: currentFingerprint,
}); });
throw new Error('Hardware fingerprint mismatch - key was encrypted on different hardware'); throw new Error(
"Hardware fingerprint mismatch - key was encrypted on different hardware",
);
} }
const kek = this.deriveKEK(); const kek = this.deriveKEK();
const decipher = crypto.createDecipheriv('aes-256-gcm', kek, Buffer.from(protectedData.iv, 'hex')) as any; const decipher = crypto.createDecipheriv(
decipher.setAuthTag(Buffer.from(protectedData.tag, 'hex')); "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'); let decrypted = decipher.update(protectedData.data, "hex", "hex");
decrypted += decipher.final('hex'); decrypted += decipher.final("hex");
databaseLogger.debug('Master key decrypted successfully', {
operation: 'master_key_decryption',
version: protectedData.version
});
return decrypted; return decrypted;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to decrypt master key', error, { databaseLogger.error("Failed to decrypt master key", error, {
operation: 'master_key_decryption_failed' operation: "master_key_decryption_failed",
}); });
throw new Error(`Master key decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Master key decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
static isProtectedKey(data: string): boolean { static isProtectedKey(data: string): boolean {
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
return !!(parsed.data && parsed.iv && parsed.tag && parsed.version && parsed.fingerprint); return !!(
parsed.data &&
parsed.iv &&
parsed.tag &&
parsed.version &&
parsed.fingerprint
);
} catch { } catch {
return false; return false;
} }
@@ -148,21 +150,21 @@ class MasterKeyProtection {
static validateProtection(): boolean { static validateProtection(): boolean {
try { try {
const testKey = crypto.randomBytes(32).toString('hex'); const testKey = crypto.randomBytes(32).toString("hex");
const encrypted = this.encryptMasterKey(testKey); const encrypted = this.encryptMasterKey(testKey);
const decrypted = this.decryptMasterKey(encrypted); const decrypted = this.decryptMasterKey(encrypted);
const isValid = decrypted === testKey; const isValid = decrypted === testKey;
databaseLogger.info('Master key protection validation completed', { databaseLogger.info("Master key protection validation completed", {
operation: 'protection_validation', operation: "protection_validation",
result: isValid ? 'passed' : 'failed' result: isValid ? "passed" : "failed",
}); });
return isValid; return isValid;
} catch (error) { } catch (error) {
databaseLogger.error('Master key protection validation failed', error, { databaseLogger.error("Master key protection validation failed", error, {
operation: 'protection_validation_failed' operation: "protection_validation_failed",
}); });
return false; return false;
} }
@@ -179,12 +181,15 @@ class MasterKeyProtection {
} }
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey); const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16); const currentFingerprint = this.generateDeviceFingerprint().substring(
0,
16,
);
return { return {
version: protectedData.version, version: protectedData.version,
fingerprint: protectedData.fingerprint, fingerprint: protectedData.fingerprint,
isCurrentDevice: protectedData.fingerprint === currentFingerprint isCurrentDevice: protectedData.fingerprint === currentFingerprint,
}; };
} catch { } catch {
return null; return null;

View File

@@ -1,5 +1,5 @@
// Import SSH2 using ES modules // Import SSH2 using ES modules
import ssh2Pkg from 'ssh2'; import ssh2Pkg from "ssh2";
const ssh2Utils = ssh2Pkg.utils; const ssh2Utils = ssh2Pkg.utils;
// Simple fallback SSH key type detection // Simple fallback SSH key type detection
@@ -7,117 +7,120 @@ function detectKeyTypeFromContent(keyContent: string): string {
const content = keyContent.trim(); const content = keyContent.trim();
// Check for OpenSSH format headers // Check for OpenSSH format headers
if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
// Look for key type indicators in the content // Look for key type indicators in the content
if (content.includes('ssh-ed25519') || content.includes('AAAAC3NzaC1lZDI1NTE5')) { if (
return 'ssh-ed25519'; content.includes("ssh-ed25519") ||
content.includes("AAAAC3NzaC1lZDI1NTE5")
) {
return "ssh-ed25519";
} }
if (content.includes('ssh-rsa') || content.includes('AAAAB3NzaC1yc2E')) { if (content.includes("ssh-rsa") || content.includes("AAAAB3NzaC1yc2E")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
if (content.includes('ecdsa-sha2-nistp256')) { if (content.includes("ecdsa-sha2-nistp256")) {
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
if (content.includes('ecdsa-sha2-nistp384')) { if (content.includes("ecdsa-sha2-nistp384")) {
return 'ecdsa-sha2-nistp384'; return "ecdsa-sha2-nistp384";
} }
if (content.includes('ecdsa-sha2-nistp521')) { if (content.includes("ecdsa-sha2-nistp521")) {
return 'ecdsa-sha2-nistp521'; return "ecdsa-sha2-nistp521";
} }
// For OpenSSH format, try to detect by analyzing the base64 content structure // For OpenSSH format, try to detect by analyzing the base64 content structure
try { try {
const base64Content = content const base64Content = content
.replace('-----BEGIN OPENSSH PRIVATE KEY-----', '') .replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
.replace('-----END OPENSSH PRIVATE KEY-----', '') .replace("-----END OPENSSH PRIVATE KEY-----", "")
.replace(/\s/g, ''); .replace(/\s/g, "");
// OpenSSH format starts with "openssh-key-v1" followed by key type // OpenSSH format starts with "openssh-key-v1" followed by key type
const decoded = Buffer.from(base64Content, 'base64').toString('binary'); const decoded = Buffer.from(base64Content, "base64").toString("binary");
if (decoded.includes('ssh-rsa')) { if (decoded.includes("ssh-rsa")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
if (decoded.includes('ssh-ed25519')) { if (decoded.includes("ssh-ed25519")) {
return 'ssh-ed25519'; return "ssh-ed25519";
} }
if (decoded.includes('ecdsa-sha2-nistp256')) { if (decoded.includes("ecdsa-sha2-nistp256")) {
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
if (decoded.includes('ecdsa-sha2-nistp384')) { if (decoded.includes("ecdsa-sha2-nistp384")) {
return 'ecdsa-sha2-nistp384'; return "ecdsa-sha2-nistp384";
} }
if (decoded.includes('ecdsa-sha2-nistp521')) { if (decoded.includes("ecdsa-sha2-nistp521")) {
return 'ecdsa-sha2-nistp521'; return "ecdsa-sha2-nistp521";
} }
// Default to RSA for OpenSSH format if we can't detect specifically // Default to RSA for OpenSSH format if we can't detect specifically
return 'ssh-rsa'; return "ssh-rsa";
} catch (error) { } catch (error) {
console.warn('Failed to decode OpenSSH key content:', error); console.warn("Failed to decode OpenSSH key content:", error);
// If decoding fails, default to RSA as it's most common for OpenSSH format // If decoding fails, default to RSA as it's most common for OpenSSH format
return 'ssh-rsa'; return "ssh-rsa";
} }
} }
// Check for traditional PEM headers // Check for traditional PEM headers
if (content.includes('-----BEGIN RSA PRIVATE KEY-----')) { if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
if (content.includes('-----BEGIN DSA PRIVATE KEY-----')) { if (content.includes("-----BEGIN DSA PRIVATE KEY-----")) {
return 'ssh-dss'; return "ssh-dss";
} }
if (content.includes('-----BEGIN EC PRIVATE KEY-----')) { if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
return 'ecdsa-sha2-nistp256'; // Default ECDSA type return "ecdsa-sha2-nistp256"; // Default ECDSA type
} }
// Check for PKCS#8 format (modern format) // Check for PKCS#8 format (modern format)
if (content.includes('-----BEGIN PRIVATE KEY-----')) { if (content.includes("-----BEGIN PRIVATE KEY-----")) {
// Try to decode and analyze the DER structure for better detection // Try to decode and analyze the DER structure for better detection
try { try {
const base64Content = content const base64Content = content
.replace('-----BEGIN PRIVATE KEY-----', '') .replace("-----BEGIN PRIVATE KEY-----", "")
.replace('-----END PRIVATE KEY-----', '') .replace("-----END PRIVATE KEY-----", "")
.replace(/\s/g, ''); .replace(/\s/g, "");
const decoded = Buffer.from(base64Content, 'base64'); const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString('binary'); const decodedString = decoded.toString("binary");
// Check for algorithm identifiers in the DER structure // Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) { if (decodedString.includes("1.2.840.113549.1.1.1")) {
// RSA OID // RSA OID
return 'ssh-rsa'; return "ssh-rsa";
} else if (decodedString.includes('1.2.840.10045.2.1')) { } else if (decodedString.includes("1.2.840.10045.2.1")) {
// EC Private Key OID - this indicates ECDSA // EC Private Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) { if (decodedString.includes("1.2.840.10045.3.1.7")) {
// prime256v1 curve OID // prime256v1 curve OID
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
return 'ecdsa-sha2-nistp256'; // Default to P-256 return "ecdsa-sha2-nistp256"; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) { } else if (decodedString.includes("1.3.101.112")) {
// Ed25519 OID // Ed25519 OID
return 'ssh-ed25519'; return "ssh-ed25519";
} }
} catch (error) { } catch (error) {
// If decoding fails, fall back to length-based detection // If decoding fails, fall back to length-based detection
console.warn('Failed to decode private key for type detection:', error); console.warn("Failed to decode private key for type detection:", error);
} }
// Fallback: Try to detect key type from the content structure // Fallback: Try to detect key type from the content structure
// This is a fallback for PKCS#8 format keys // This is a fallback for PKCS#8 format keys
if (content.length < 800) { if (content.length < 800) {
// Ed25519 keys are typically shorter // Ed25519 keys are typically shorter
return 'ssh-ed25519'; return "ssh-ed25519";
} else if (content.length > 1600) { } else if (content.length > 1600) {
// RSA keys are typically longer // RSA keys are typically longer
return 'ssh-rsa'; return "ssh-rsa";
} else { } else {
// ECDSA keys are typically medium length // ECDSA keys are typically medium length
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
} }
return 'unknown'; return "unknown";
} }
// Detect public key type from public key content // Detect public key type from public key content
@@ -125,92 +128,92 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim(); const content = publicKeyContent.trim();
// SSH public keys start with the key type // SSH public keys start with the key type
if (content.startsWith('ssh-rsa ')) { if (content.startsWith("ssh-rsa ")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
if (content.startsWith('ssh-ed25519 ')) { if (content.startsWith("ssh-ed25519 ")) {
return 'ssh-ed25519'; return "ssh-ed25519";
} }
if (content.startsWith('ecdsa-sha2-nistp256 ')) { if (content.startsWith("ecdsa-sha2-nistp256 ")) {
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
if (content.startsWith('ecdsa-sha2-nistp384 ')) { if (content.startsWith("ecdsa-sha2-nistp384 ")) {
return 'ecdsa-sha2-nistp384'; return "ecdsa-sha2-nistp384";
} }
if (content.startsWith('ecdsa-sha2-nistp521 ')) { if (content.startsWith("ecdsa-sha2-nistp521 ")) {
return 'ecdsa-sha2-nistp521'; return "ecdsa-sha2-nistp521";
} }
if (content.startsWith('ssh-dss ')) { if (content.startsWith("ssh-dss ")) {
return 'ssh-dss'; return "ssh-dss";
} }
// Check for PEM format public keys // Check for PEM format public keys
if (content.includes('-----BEGIN PUBLIC KEY-----')) { if (content.includes("-----BEGIN PUBLIC KEY-----")) {
// Try to decode the base64 content to detect key type // Try to decode the base64 content to detect key type
try { try {
const base64Content = content const base64Content = content
.replace('-----BEGIN PUBLIC KEY-----', '') .replace("-----BEGIN PUBLIC KEY-----", "")
.replace('-----END PUBLIC KEY-----', '') .replace("-----END PUBLIC KEY-----", "")
.replace(/\s/g, ''); .replace(/\s/g, "");
const decoded = Buffer.from(base64Content, 'base64'); const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString('binary'); const decodedString = decoded.toString("binary");
// Check for algorithm identifiers in the DER structure // Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) { if (decodedString.includes("1.2.840.113549.1.1.1")) {
// RSA OID // RSA OID
return 'ssh-rsa'; return "ssh-rsa";
} else if (decodedString.includes('1.2.840.10045.2.1')) { } else if (decodedString.includes("1.2.840.10045.2.1")) {
// EC Public Key OID - this indicates ECDSA // EC Public Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) { if (decodedString.includes("1.2.840.10045.3.1.7")) {
// prime256v1 curve OID // prime256v1 curve OID
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
return 'ecdsa-sha2-nistp256'; // Default to P-256 return "ecdsa-sha2-nistp256"; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) { } else if (decodedString.includes("1.3.101.112")) {
// Ed25519 OID // Ed25519 OID
return 'ssh-ed25519'; return "ssh-ed25519";
} }
} catch (error) { } catch (error) {
// If decoding fails, fall back to length-based detection // If decoding fails, fall back to length-based detection
console.warn('Failed to decode public key for type detection:', error); console.warn("Failed to decode public key for type detection:", error);
} }
// Fallback: Try to guess based on key length // Fallback: Try to guess based on key length
if (content.length < 400) { if (content.length < 400) {
return 'ssh-ed25519'; return "ssh-ed25519";
} else if (content.length > 600) { } else if (content.length > 600) {
return 'ssh-rsa'; return "ssh-rsa";
} else { } else {
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
} }
if (content.includes('-----BEGIN RSA PUBLIC KEY-----')) { if (content.includes("-----BEGIN RSA PUBLIC KEY-----")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
// Check for base64 encoded key data patterns // Check for base64 encoded key data patterns
if (content.includes('AAAAB3NzaC1yc2E')) { if (content.includes("AAAAB3NzaC1yc2E")) {
return 'ssh-rsa'; return "ssh-rsa";
} }
if (content.includes('AAAAC3NzaC1lZDI1NTE5')) { if (content.includes("AAAAC3NzaC1lZDI1NTE5")) {
return 'ssh-ed25519'; return "ssh-ed25519";
} }
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY')) { if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY")) {
return 'ecdsa-sha2-nistp256'; return "ecdsa-sha2-nistp256";
} }
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ')) { if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ")) {
return 'ecdsa-sha2-nistp384'; return "ecdsa-sha2-nistp384";
} }
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE')) { if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE")) {
return 'ecdsa-sha2-nistp521'; return "ecdsa-sha2-nistp521";
} }
if (content.includes('AAAAB3NzaC1kc3M')) { if (content.includes("AAAAB3NzaC1kc3M")) {
return 'ssh-dss'; return "ssh-dss";
} }
return 'unknown'; return "unknown";
} }
export interface KeyInfo { export interface KeyInfo {
@@ -239,90 +242,114 @@ export interface KeyPairValidationResult {
/** /**
* Parse SSH private key and extract public key and type information * Parse SSH private key and extract public key and type information
*/ */
export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo { export function parseSSHKey(
console.log('=== SSH Key Parsing Debug ==='); privateKeyData: string,
console.log('Key length:', privateKeyData?.length || 'undefined'); passphrase?: string,
console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined'); ): KeyInfo {
console.log('ssh2Utils available:', typeof ssh2Utils); console.log("=== SSH Key Parsing Debug ===");
console.log('parseKey function available:', typeof ssh2Utils?.parseKey); 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 { try {
let keyType = 'unknown'; let keyType = "unknown";
let publicKey = ''; let publicKey = "";
let useSSH2 = false; let useSSH2 = false;
// Try SSH2 first if available // Try SSH2 first if available
if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') { if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
try { try {
console.log('Calling ssh2Utils.parseKey...'); console.log("Calling ssh2Utils.parseKey...");
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase); const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success'); console.log(
"parseKey returned:",
typeof parsedKey,
parsedKey instanceof Error ? parsedKey.message : "success",
);
if (!(parsedKey instanceof Error)) { if (!(parsedKey instanceof Error)) {
// Extract key type // Extract key type
if (parsedKey.type) { if (parsedKey.type) {
keyType = parsedKey.type; keyType = parsedKey.type;
} }
console.log('Extracted key type:', keyType); console.log("Extracted key type:", keyType);
// Generate public key in SSH format // Generate public key in SSH format
try { try {
console.log('Attempting to generate public key...'); console.log("Attempting to generate public key...");
const publicKeyBuffer = parsedKey.getPublicSSH(); const publicKeyBuffer = parsedKey.getPublicSSH();
console.log('Public key buffer type:', typeof publicKeyBuffer); console.log("Public key buffer type:", typeof publicKeyBuffer);
console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer)); console.log(
"Public key buffer is Buffer:",
Buffer.isBuffer(publicKeyBuffer),
);
// ssh2's getPublicSSH() returns binary SSH protocol data, not text // ssh2's getPublicSSH() returns binary SSH protocol data, not text
// We need to convert this to proper SSH public key format // We need to convert this to proper SSH public key format
if (Buffer.isBuffer(publicKeyBuffer)) { if (Buffer.isBuffer(publicKeyBuffer)) {
// Convert binary SSH data to base64 and create proper SSH key format // Convert binary SSH data to base64 and create proper SSH key format
const base64Data = publicKeyBuffer.toString('base64'); const base64Data = publicKeyBuffer.toString("base64");
// Create proper SSH public key format: "keytype base64data" // Create proper SSH public key format: "keytype base64data"
if (keyType === 'ssh-rsa') { if (keyType === "ssh-rsa") {
publicKey = `ssh-rsa ${base64Data}`; publicKey = `ssh-rsa ${base64Data}`;
} else if (keyType === 'ssh-ed25519') { } else if (keyType === "ssh-ed25519") {
publicKey = `ssh-ed25519 ${base64Data}`; publicKey = `ssh-ed25519 ${base64Data}`;
} else if (keyType.startsWith('ecdsa-')) { } else if (keyType.startsWith("ecdsa-")) {
publicKey = `${keyType} ${base64Data}`; publicKey = `${keyType} ${base64Data}`;
} else { } else {
publicKey = `${keyType} ${base64Data}`; publicKey = `${keyType} ${base64Data}`;
} }
console.log('Generated SSH public key format, length:', publicKey.length); console.log(
console.log('Public key starts with:', publicKey.substring(0, 50)); "Generated SSH public key format, length:",
publicKey.length,
);
console.log(
"Public key starts with:",
publicKey.substring(0, 50),
);
} else { } else {
console.warn('Unexpected public key buffer type'); console.warn("Unexpected public key buffer type");
publicKey = ''; publicKey = "";
} }
} catch (error) { } catch (error) {
console.warn('Failed to generate public key:', error); console.warn("Failed to generate public key:", error);
publicKey = ''; publicKey = "";
} }
useSSH2 = true; useSSH2 = true;
console.log(`SSH key parsed successfully with SSH2: ${keyType}`); console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
} else { } else {
console.warn('SSH2 parsing failed:', parsedKey.message); console.warn("SSH2 parsing failed:", parsedKey.message);
} }
} catch (error) { } catch (error) {
console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error); console.warn(
"SSH2 parsing exception:",
error instanceof Error ? error.message : error,
);
} }
} else { } else {
console.warn('SSH2 parseKey function not available'); console.warn("SSH2 parseKey function not available");
} }
// Fallback to content-based detection // Fallback to content-based detection
if (!useSSH2) { if (!useSSH2) {
console.log('Using fallback key type detection...'); console.log("Using fallback key type detection...");
keyType = detectKeyTypeFromContent(privateKeyData); keyType = detectKeyTypeFromContent(privateKeyData);
console.log(`Fallback detected key type: ${keyType}`); console.log(`Fallback detected key type: ${keyType}`);
// For fallback, we can't generate public key but the detection is still useful // For fallback, we can't generate public key but the detection is still useful
publicKey = ''; publicKey = "";
if (keyType !== 'unknown') { if (keyType !== "unknown") {
console.log(`SSH key type detected successfully with fallback: ${keyType}`); console.log(
`SSH key type detected successfully with fallback: ${keyType}`,
);
} }
} }
@@ -330,34 +357,38 @@ export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInf
privateKey: privateKeyData, privateKey: privateKeyData,
publicKey, publicKey,
keyType, keyType,
success: keyType !== 'unknown' success: keyType !== "unknown",
}; };
} catch (error) { } catch (error) {
console.error('Exception during SSH key parsing:', error); console.error("Exception during SSH key parsing:", error);
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack'); console.error(
"Error stack:",
error instanceof Error ? error.stack : "No stack",
);
// Final fallback - try content detection // Final fallback - try content detection
try { try {
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData); const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
if (fallbackKeyType !== 'unknown') { if (fallbackKeyType !== "unknown") {
console.log(`Final fallback detection successful: ${fallbackKeyType}`); console.log(`Final fallback detection successful: ${fallbackKeyType}`);
return { return {
privateKey: privateKeyData, privateKey: privateKeyData,
publicKey: '', publicKey: "",
keyType: fallbackKeyType, keyType: fallbackKeyType,
success: true success: true,
}; };
} }
} catch (fallbackError) { } catch (fallbackError) {
console.error('Even fallback detection failed:', fallbackError); console.error("Even fallback detection failed:", fallbackError);
} }
return { return {
privateKey: privateKeyData, privateKey: privateKeyData,
publicKey: '', publicKey: "",
keyType: 'unknown', keyType: "unknown",
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error parsing key' error:
error instanceof Error ? error.message : "Unknown error parsing key",
}; };
} }
} }
@@ -366,9 +397,12 @@ export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInf
* Parse SSH public key and extract type information * Parse SSH public key and extract type information
*/ */
export function parsePublicKey(publicKeyData: string): PublicKeyInfo { export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
console.log('=== SSH Public Key Parsing Debug ==='); console.log("=== SSH Public Key Parsing Debug ===");
console.log('Public key length:', publicKeyData?.length || 'undefined'); console.log("Public key length:", publicKeyData?.length || "undefined");
console.log('First 100 chars:', publicKeyData?.substring(0, 100) || 'undefined'); console.log(
"First 100 chars:",
publicKeyData?.substring(0, 100) || "undefined",
);
try { try {
const keyType = detectPublicKeyTypeFromContent(publicKeyData); const keyType = detectPublicKeyTypeFromContent(publicKeyData);
@@ -377,15 +411,18 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
return { return {
publicKey: publicKeyData, publicKey: publicKeyData,
keyType, keyType,
success: keyType !== 'unknown' success: keyType !== "unknown",
}; };
} catch (error) { } catch (error) {
console.error('Exception during SSH public key parsing:', error); console.error("Exception during SSH public key parsing:", error);
return { return {
publicKey: publicKeyData, publicKey: publicKeyData,
keyType: 'unknown', keyType: "unknown",
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error parsing public key' error:
error instanceof Error
? error.message
: "Unknown error parsing public key",
}; };
} }
} }
@@ -397,11 +434,11 @@ export function detectKeyType(privateKeyData: string): string {
try { try {
const parsedKey = ssh2Utils.parseKey(privateKeyData); const parsedKey = ssh2Utils.parseKey(privateKeyData);
if (parsedKey instanceof Error) { if (parsedKey instanceof Error) {
return 'unknown'; return "unknown";
} }
return parsedKey.type || 'unknown'; return parsedKey.type || "unknown";
} catch (error) { } catch (error) {
return 'unknown'; return "unknown";
} }
} }
@@ -410,15 +447,15 @@ export function detectKeyType(privateKeyData: string): string {
*/ */
export function getFriendlyKeyTypeName(keyType: string): string { export function getFriendlyKeyTypeName(keyType: string): string {
const keyTypeMap: Record<string, string> = { const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA', "ssh-rsa": "RSA",
'ssh-ed25519': 'Ed25519', "ssh-ed25519": "Ed25519",
'ecdsa-sha2-nistp256': 'ECDSA P-256', "ecdsa-sha2-nistp256": "ECDSA P-256",
'ecdsa-sha2-nistp384': 'ECDSA P-384', "ecdsa-sha2-nistp384": "ECDSA P-384",
'ecdsa-sha2-nistp521': 'ECDSA P-521', "ecdsa-sha2-nistp521": "ECDSA P-521",
'ssh-dss': 'DSA', "ssh-dss": "DSA",
'rsa-sha2-256': 'RSA-SHA2-256', "rsa-sha2-256": "RSA-SHA2-256",
'rsa-sha2-512': 'RSA-SHA2-512', "rsa-sha2-512": "RSA-SHA2-512",
'unknown': 'Unknown' unknown: "Unknown",
}; };
return keyTypeMap[keyType] || keyType; return keyTypeMap[keyType] || keyType;
@@ -427,25 +464,37 @@ export function getFriendlyKeyTypeName(keyType: string): string {
/** /**
* Validate if a private key and public key form a valid key pair * Validate if a private key and public key form a valid key pair
*/ */
export function validateKeyPair(privateKeyData: string, publicKeyData: string, passphrase?: string): KeyPairValidationResult { export function validateKeyPair(
console.log('=== Key Pair Validation Debug ==='); privateKeyData: string,
console.log('Private key length:', privateKeyData?.length || 'undefined'); publicKeyData: string,
console.log('Public key length:', publicKeyData?.length || 'undefined'); 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 { try {
// First parse the private key and try to generate public key // First parse the private key and try to generate public key
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase); const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
const publicKeyInfo = parsePublicKey(publicKeyData); const publicKeyInfo = parsePublicKey(publicKeyData);
console.log('Private key parsing result:', privateKeyInfo.success, privateKeyInfo.keyType); console.log(
console.log('Public key parsing result:', publicKeyInfo.success, publicKeyInfo.keyType); "Private key parsing result:",
privateKeyInfo.success,
privateKeyInfo.keyType,
);
console.log(
"Public key parsing result:",
publicKeyInfo.success,
publicKeyInfo.keyType,
);
if (!privateKeyInfo.success) { if (!privateKeyInfo.success) {
return { return {
isValid: false, isValid: false,
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
error: `Invalid private key: ${privateKeyInfo.error}` error: `Invalid private key: ${privateKeyInfo.error}`,
}; };
} }
@@ -454,7 +503,7 @@ export function validateKeyPair(privateKeyData: string, publicKeyData: string, p
isValid: false, isValid: false,
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
error: `Invalid public key: ${publicKeyInfo.error}` error: `Invalid public key: ${publicKeyInfo.error}`,
}; };
} }
@@ -464,7 +513,7 @@ export function validateKeyPair(privateKeyData: string, publicKeyData: string, p
isValid: false, isValid: false,
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}` error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`,
}; };
} }
@@ -473,27 +522,34 @@ export function validateKeyPair(privateKeyData: string, publicKeyData: string, p
const generatedPublicKey = privateKeyInfo.publicKey.trim(); const generatedPublicKey = privateKeyInfo.publicKey.trim();
const providedPublicKey = publicKeyData.trim(); const providedPublicKey = publicKeyData.trim();
console.log('Generated public key length:', generatedPublicKey.length); console.log("Generated public key length:", generatedPublicKey.length);
console.log('Provided public key length:', providedPublicKey.length); console.log("Provided public key length:", providedPublicKey.length);
// Compare the key data part (excluding comments) // Compare the key data part (excluding comments)
const generatedKeyParts = generatedPublicKey.split(' '); const generatedKeyParts = generatedPublicKey.split(" ");
const providedKeyParts = providedPublicKey.split(' '); const providedKeyParts = providedPublicKey.split(" ");
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) { if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
// Compare key type and key data (first two parts) // Compare key type and key data (first two parts)
const generatedKeyData = generatedKeyParts[0] + ' ' + generatedKeyParts[1]; const generatedKeyData =
const providedKeyData = providedKeyParts[0] + ' ' + providedKeyParts[1]; generatedKeyParts[0] + " " + generatedKeyParts[1];
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...'); console.log(
console.log('Provided key data:', providedKeyData.substring(0, 50) + '...'); "Generated key data:",
generatedKeyData.substring(0, 50) + "...",
);
console.log(
"Provided key data:",
providedKeyData.substring(0, 50) + "...",
);
if (generatedKeyData === providedKeyData) { if (generatedKeyData === providedKeyData) {
return { return {
isValid: true, isValid: true,
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey generatedPublicKey: generatedPublicKey,
}; };
} else { } else {
return { return {
@@ -501,7 +557,7 @@ export function validateKeyPair(privateKeyData: string, publicKeyData: string, p
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey, generatedPublicKey: generatedPublicKey,
error: 'Public key does not match the private key' error: "Public key does not match the private key",
}; };
} }
} }
@@ -512,16 +568,18 @@ export function validateKeyPair(privateKeyData: string, publicKeyData: string, p
isValid: true, // Assume valid if types match and no errors isValid: true, // Assume valid if types match and no errors
privateKeyType: privateKeyInfo.keyType, privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType,
error: 'Unable to verify key pair match, but key types are compatible' error: "Unable to verify key pair match, but key types are compatible",
}; };
} catch (error) { } catch (error) {
console.error('Exception during key pair validation:', error); console.error("Exception during key pair validation:", error);
return { return {
isValid: false, isValid: false,
privateKeyType: 'unknown', privateKeyType: "unknown",
publicKeyType: 'unknown', publicKeyType: "unknown",
error: error instanceof Error ? error.message : 'Unknown error during validation' error:
error instanceof Error
? error.message
: "Unknown error during validation",
}; };
} }
} }

View File

@@ -22,7 +22,7 @@ export interface ElectronAPI {
createTempFile: (fileData: { createTempFile: (fileData: {
fileName: string; fileName: string;
content: string; content: string;
encoding?: 'base64' | 'utf8'; encoding?: "base64" | "utf8";
}) => Promise<{ }) => Promise<{
success: boolean; success: boolean;
tempId?: string; tempId?: string;
@@ -35,7 +35,7 @@ export interface ElectronAPI {
files: Array<{ files: Array<{
relativePath: string; relativePath: string;
content: string; content: string;
encoding?: 'base64' | 'utf8'; encoding?: "base64" | "utf8";
}>; }>;
}) => Promise<{ }) => Promise<{
success: boolean; success: boolean;

View File

@@ -21,7 +21,18 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import { Shield, Trash2, Users, Database, Key, Lock, Download, Upload, HardDrive, FileArchive } 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";
@@ -280,9 +291,9 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json" "Content-Type": "application/json",
} },
}); });
if (response.ok) { if (response.ok) {
@@ -305,8 +316,8 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
}); });
@@ -326,7 +337,9 @@ export function AdminSettings({
const handleMigrateData = async (dryRun: boolean = false) => { const handleMigrateData = async (dryRun: boolean = false) => {
setMigrationLoading(true); setMigrationLoading(true);
setMigrationProgress(dryRun ? t("admin.runningVerification") : t("admin.startingMigration")); setMigrationProgress(
dryRun ? t("admin.runningVerification") : t("admin.startingMigration"),
);
try { try {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
@@ -337,8 +350,8 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
body: JSON.stringify({ dryRun }), body: JSON.stringify({ dryRun }),
}); });
@@ -357,7 +370,9 @@ export function AdminSettings({
throw new Error("Migration failed"); throw new Error("Migration failed");
} }
} catch (err) { } catch (err) {
toast.error(dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed")); toast.error(
dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed"),
);
setMigrationProgress("Failed"); setMigrationProgress("Failed");
} finally { } finally {
setMigrationLoading(false); setMigrationLoading(false);
@@ -377,10 +392,10 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
body: JSON.stringify({}) body: JSON.stringify({}),
}); });
if (response.ok) { if (response.ok) {
@@ -412,15 +427,15 @@ export function AdminSettings({
// Create FormData for file upload // Create FormData for file upload
const formData = new FormData(); const formData = new FormData();
formData.append('file', importFile); formData.append("file", importFile);
formData.append('backupCurrent', 'true'); formData.append("backupCurrent", "true");
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
}, },
body: formData body: formData,
}); });
if (response.ok) { if (response.ok) {
@@ -430,7 +445,9 @@ export function AdminSettings({
setImportFile(null); setImportFile(null);
await fetchEncryptionStatus(); // Refresh status await fetchEncryptionStatus(); // Refresh status
} else { } else {
toast.error(`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`); toast.error(
`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`,
);
} }
} else { } else {
throw new Error("Import failed"); throw new Error("Import failed");
@@ -453,10 +470,10 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
body: JSON.stringify({}) body: JSON.stringify({}),
}); });
if (response.ok) { if (response.ok) {
@@ -911,7 +928,9 @@ export function AdminSettings({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3> <h3 className="text-lg font-semibold">
{t("admin.databaseSecurity")}
</h3>
</div> </div>
{encryptionStatus && ( {encryptionStatus && (
@@ -926,11 +945,19 @@ export function AdminSettings({
<Key className="h-4 w-4 text-yellow-500" /> <Key className="h-4 w-4 text-yellow-500" />
)} )}
<div> <div>
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div> <div className="text-sm font-medium">
<div className={`text-xs ${ {t("admin.encryptionStatus")}
encryptionStatus.encryption?.enabled ? 'text-green-500' : 'text-yellow-500' </div>
}`}> <div
{encryptionStatus.encryption?.enabled ? t("admin.enabled") : t("admin.disabled")} 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> </div>
@@ -940,11 +967,19 @@ export function AdminSettings({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-blue-500" /> <Shield className="h-4 w-4 text-blue-500" />
<div> <div>
<div className="text-sm font-medium">{t("admin.keyProtection")}</div> <div className="text-sm font-medium">
<div className={`text-xs ${ {t("admin.keyProtection")}
encryptionStatus.encryption?.key?.kekProtected ? 'text-green-500' : 'text-yellow-500' </div>
}`}> <div
{encryptionStatus.encryption?.key?.kekProtected ? t("admin.active") : t("admin.legacy")} 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> </div>
@@ -954,14 +989,19 @@ export function AdminSettings({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-4 w-4 text-purple-500" /> <Database className="h-4 w-4 text-purple-500" />
<div> <div>
<div className="text-sm font-medium">{t("admin.dataStatus")}</div> <div className="text-sm font-medium">
<div className={`text-xs ${ {t("admin.dataStatus")}
</div>
<div
className={`text-xs ${
encryptionStatus.migration?.migrationCompleted encryptionStatus.migration?.migrationCompleted
? 'text-green-500' ? "text-green-500"
: encryptionStatus.migration?.migrationRequired : encryptionStatus.migration
? 'text-yellow-500' ?.migrationRequired
: 'text-muted-foreground' ? "text-yellow-500"
}`}> : "text-muted-foreground"
}`}
>
{encryptionStatus.migration?.migrationCompleted {encryptionStatus.migration?.migrationCompleted
? t("admin.encrypted") ? t("admin.encrypted")
: encryptionStatus.migration?.migrationRequired : encryptionStatus.migration?.migrationRequired
@@ -980,14 +1020,18 @@ export function AdminSettings({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-blue-500" /> <Shield className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.initializeEncryption")}</h4> <h4 className="font-medium">
{t("admin.initializeEncryption")}
</h4>
</div> </div>
<Button <Button
onClick={handleInitializeEncryption} onClick={handleInitializeEncryption}
disabled={encryptionLoading} disabled={encryptionLoading}
className="w-full" className="w-full"
> >
{encryptionLoading ? t("admin.initializing") : t("admin.initialize")} {encryptionLoading
? t("admin.initializing")
: t("admin.initialize")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -998,10 +1042,14 @@ export function AdminSettings({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-4 w-4 text-yellow-500" /> <Database className="h-4 w-4 text-yellow-500" />
<h4 className="font-medium">{t("admin.migrateData")}</h4> <h4 className="font-medium">
{t("admin.migrateData")}
</h4>
</div> </div>
{migrationProgress && ( {migrationProgress && (
<div className="text-sm text-blue-600">{migrationProgress}</div> <div className="text-sm text-blue-600">
{migrationProgress}
</div>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -1019,7 +1067,9 @@ export function AdminSettings({
size="sm" size="sm"
className="flex-1" className="flex-1"
> >
{migrationLoading ? t("admin.migrating") : t("admin.migrate")} {migrationLoading
? t("admin.migrating")
: t("admin.migrate")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1030,7 +1080,9 @@ export function AdminSettings({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-4 w-4 text-blue-500" /> <Database className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.backup")}</h4> <h4 className="font-medium">
{t("admin.backup")}
</h4>
</div> </div>
<Button <Button
onClick={handleCreateBackup} onClick={handleCreateBackup}
@@ -1038,11 +1090,15 @@ export function AdminSettings({
variant="outline" variant="outline"
className="w-full" className="w-full"
> >
{backupLoading ? t("admin.creatingBackup") : t("admin.createBackup")} {backupLoading
? t("admin.creatingBackup")
: t("admin.createBackup")}
</Button> </Button>
{backupPath && ( {backupPath && (
<div className="p-2 bg-muted rounded border"> <div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">{backupPath}</div> <div className="text-xs font-mono break-all">
{backupPath}
</div>
</div> </div>
)} )}
</div> </div>
@@ -1054,7 +1110,9 @@ export function AdminSettings({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" /> <Upload className="h-4 w-4 text-green-500" />
<h4 className="font-medium">{t("admin.exportImport")}</h4> <h4 className="font-medium">
{t("admin.exportImport")}
</h4>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Button <Button
@@ -1064,11 +1122,15 @@ export function AdminSettings({
size="sm" size="sm"
className="w-full" className="w-full"
> >
{exportLoading ? t("admin.exporting") : t("admin.export")} {exportLoading
? t("admin.exporting")
: t("admin.export")}
</Button> </Button>
{exportPath && ( {exportPath && (
<div className="p-2 bg-muted rounded border"> <div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">{exportPath}</div> <div className="text-xs font-mono break-all">
{exportPath}
</div>
</div> </div>
)} )}
</div> </div>
@@ -1076,7 +1138,9 @@ export function AdminSettings({
<input <input
type="file" type="file"
accept=".sqlite,.termix-export.sqlite,.db" accept=".sqlite,.termix-export.sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)} 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" 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 <Button
@@ -1086,7 +1150,9 @@ export function AdminSettings({
size="sm" size="sm"
className="w-full" className="w-full"
> >
{importLoading ? t("admin.importing") : t("admin.import")} {importLoading
? t("admin.importing")
: t("admin.import")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1097,7 +1163,9 @@ export function AdminSettings({
{!encryptionStatus && ( {!encryptionStatus && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-muted-foreground">{t("admin.loadingEncryptionStatus")}</div> <div className="text-muted-foreground">
{t("admin.loadingEncryptionStatus")}
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -50,11 +50,13 @@ export function CredentialEditor({
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false); const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null); const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null); const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false); string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null); const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -230,8 +232,11 @@ export function CredentialEditor({
}, []); }, []);
// Detect key type function // Detect key type function
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => { const handleKeyTypeDetection = async (
if (!keyValue || keyValue.trim() === '') { keyValue: string,
keyPassword?: string,
) => {
if (!keyValue || keyValue.trim() === "") {
setDetectedKeyType(null); setDetectedKeyType(null);
return; return;
} }
@@ -242,12 +247,12 @@ export function CredentialEditor({
if (result.success) { if (result.success) {
setDetectedKeyType(result.keyType); setDetectedKeyType(result.keyType);
} else { } else {
setDetectedKeyType('invalid'); setDetectedKeyType("invalid");
console.warn('Key detection failed:', result.error); console.warn("Key detection failed:", result.error);
} }
} catch (error) { } catch (error) {
setDetectedKeyType('error'); setDetectedKeyType("error");
console.error('Key type detection error:', error); console.error("Key type detection error:", error);
} finally { } finally {
setKeyDetectionLoading(false); setKeyDetectionLoading(false);
} }
@@ -265,7 +270,7 @@ export function CredentialEditor({
// Detect public key type function // Detect public key type function
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => { const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === '') { if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null); setDetectedPublicKeyType(null);
return; return;
} }
@@ -276,12 +281,12 @@ export function CredentialEditor({
if (result.success) { if (result.success) {
setDetectedPublicKeyType(result.keyType); setDetectedPublicKeyType(result.keyType);
} else { } else {
setDetectedPublicKeyType('invalid'); setDetectedPublicKeyType("invalid");
console.warn('Public key detection failed:', result.error); console.warn("Public key detection failed:", result.error);
} }
} catch (error) { } catch (error) {
setDetectedPublicKeyType('error'); setDetectedPublicKeyType("error");
console.error('Public key type detection error:', error); console.error("Public key type detection error:", error);
} finally { } finally {
setPublicKeyDetectionLoading(false); setPublicKeyDetectionLoading(false);
} }
@@ -297,20 +302,19 @@ export function CredentialEditor({
}, 1000); }, 1000);
}; };
const getFriendlyKeyTypeName = (keyType: string): string => { const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = { const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA (SSH)', "ssh-rsa": "RSA (SSH)",
'ssh-ed25519': 'Ed25519 (SSH)', "ssh-ed25519": "Ed25519 (SSH)",
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)', "ecdsa-sha2-nistp256": "ECDSA P-256 (SSH)",
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)', "ecdsa-sha2-nistp384": "ECDSA P-384 (SSH)",
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)', "ecdsa-sha2-nistp521": "ECDSA P-521 (SSH)",
'ssh-dss': 'DSA (SSH)', "ssh-dss": "DSA (SSH)",
'rsa-sha2-256': 'RSA-SHA2-256', "rsa-sha2-256": "RSA-SHA2-256",
'rsa-sha2-512': 'RSA-SHA2-512', "rsa-sha2-512": "RSA-SHA2-512",
'invalid': 'Invalid Key', invalid: "Invalid Key",
'error': 'Detection Error', error: "Detection Error",
'unknown': 'Unknown' unknown: "Unknown",
}; };
return keyTypeMap[keyType] || keyType; return keyTypeMap[keyType] || keyType;
}; };
@@ -418,7 +422,6 @@ export function CredentialEditor({
}; };
}, [folderDropdownOpen]); }, [folderDropdownOpen]);
return ( return (
<div <div
className="flex-1 flex flex-col h-full min-h-0 w-full" className="flex-1 flex flex-col h-full min-h-0 w-full"
@@ -680,12 +683,15 @@ export function CredentialEditor({
{/* Key Generation Passphrase Input */} {/* Key Generation Passphrase Input */}
<div className="mb-3"> <div className="mb-3">
<FormLabel className="text-sm mb-2 block"> <FormLabel className="text-sm mb-2 block">
{t("credentials.keyPassword")} ({t("credentials.optional")}) {t("credentials.keyPassword")} (
{t("credentials.optional")})
</FormLabel> </FormLabel>
<PasswordInput <PasswordInput
placeholder={t("placeholders.keyPassword")} placeholder={t("placeholders.keyPassword")}
value={keyGenerationPassphrase} value={keyGenerationPassphrase}
onChange={(e) => setKeyGenerationPassphrase(e.target.value)} onChange={(e) =>
setKeyGenerationPassphrase(e.target.value)
}
className="max-w-xs" className="max-w-xs"
/> />
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@@ -700,24 +706,47 @@ export function CredentialEditor({
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { try {
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase); const result = await generateKeyPair(
"ssh-ed25519",
undefined,
keyGenerationPassphrase,
);
if (result.success) { if (result.success) {
form.setValue("key", result.privateKey); form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey); form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used // Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) { if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase); form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
} }
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase); debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey); debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" })); toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else { } else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair")); toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
} }
} catch (error) { } catch (error) {
console.error('Failed to generate Ed25519 key pair:', error); console.error(
toast.error(t("credentials.failedToGenerateKeyPair")); "Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
} }
}} }}
> >
@@ -729,24 +758,47 @@ export function CredentialEditor({
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { try {
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase); const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
keyGenerationPassphrase,
);
if (result.success) { if (result.success) {
form.setValue("key", result.privateKey); form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey); form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used // Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) { if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase); form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
} }
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase); debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey); debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" })); toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else { } else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair")); toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
} }
} catch (error) { } catch (error) {
console.error('Failed to generate ECDSA key pair:', error); console.error(
toast.error(t("credentials.failedToGenerateKeyPair")); "Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
} }
}} }}
> >
@@ -758,24 +810,47 @@ export function CredentialEditor({
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { try {
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase); const result = await generateKeyPair(
"ssh-rsa",
2048,
keyGenerationPassphrase,
);
if (result.success) { if (result.success) {
form.setValue("key", result.privateKey); form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey); form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used // Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) { if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase); form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
} }
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase); debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey); debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" })); toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else { } else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair")); toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
} }
} catch (error) { } catch (error) {
console.error('Failed to generate RSA key pair:', error); console.error(
toast.error(t("credentials.failedToGenerateKeyPair")); "Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
} }
}} }}
> >
@@ -807,9 +882,15 @@ export function CredentialEditor({
try { try {
const fileContent = await file.text(); const fileContent = await file.text();
field.onChange(fileContent); field.onChange(fileContent);
debouncedKeyDetection(fileContent, form.watch("keyPassword")); debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) { } catch (error) {
console.error('Failed to read uploaded file:', error); console.error(
"Failed to read uploaded file:",
error,
);
} }
} }
}} }}
@@ -839,22 +920,32 @@ export function CredentialEditor({
} }
onChange={(e) => { onChange={(e) => {
field.onChange(e.target.value); field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword")); debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}} }}
/> />
</FormControl> </FormControl>
{detectedKeyType && ( {detectedKeyType && (
<div className="text-sm mt-2"> <div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span> <span className="text-muted-foreground">
<span className={`font-medium ${ {t("credentials.detectedKeyType")}:{" "}
detectedKeyType === 'invalid' || detectedKeyType === 'error' </span>
? 'text-destructive' <span
: 'text-green-600' className={`font-medium ${
}`}> detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)} {getFriendlyKeyTypeName(detectedKeyType)}
</span> </span>
{keyDetectionLoading && ( {keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span> <span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)} )}
</div> </div>
)} )}
@@ -867,7 +958,8 @@ export function CredentialEditor({
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4 flex flex-col"> <FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]"> <FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} ({t("credentials.optional")}) {t("credentials.sshPublicKey")} (
{t("credentials.optional")})
</FormLabel> </FormLabel>
<div className="mb-2 flex gap-2"> <div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1"> <div className="relative inline-block flex-1">
@@ -881,9 +973,14 @@ export function CredentialEditor({
try { try {
const fileContent = await file.text(); const fileContent = await file.text();
field.onChange(fileContent); field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent); debouncedPublicKeyDetection(
fileContent,
);
} catch (error) { } catch (error) {
console.error('Failed to read uploaded public key file:', error); console.error(
"Failed to read uploaded public key file:",
error,
);
} }
} }
}} }}
@@ -905,28 +1002,59 @@ export function CredentialEditor({
className="flex-shrink-0" className="flex-shrink-0"
onClick={async () => { onClick={async () => {
const privateKey = form.watch("key"); const privateKey = form.watch("key");
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) { if (
toast.error(t("credentials.privateKeyRequiredForGeneration")); !privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return; return;
} }
try { try {
const keyPassword = form.watch("keyPassword"); const keyPassword =
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword); form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) { if (result.success && result.publicKey) {
// Set the generated public key // Set the generated public key
field.onChange(result.publicKey); field.onChange(result.publicKey);
// Trigger public key detection // Trigger public key detection
debouncedPublicKeyDetection(result.publicKey); debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(t("credentials.publicKeyGeneratedSuccessfully")); toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else { } else {
toast.error(result.error || t("credentials.failedToGeneratePublicKey")); toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
} }
} catch (error) { } catch (error) {
console.error('Failed to generate public key:', error); console.error(
toast.error(t("credentials.failedToGeneratePublicKey")); "Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
} }
}} }}
> >
@@ -935,9 +1063,7 @@ export function CredentialEditor({
</div> </div>
<FormControl> <FormControl>
<textarea <textarea
placeholder={t( placeholder={t("placeholders.pastePublicKey")}
"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" 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 || ""} value={field.value || ""}
onChange={(e) => { onChange={(e) => {
@@ -951,16 +1077,25 @@ export function CredentialEditor({
</div> </div>
{detectedPublicKeyType && field.value && ( {detectedPublicKeyType && field.value && (
<div className="text-sm mt-2"> <div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span> <span className="text-muted-foreground">
<span className={`font-medium ${ {t("credentials.detectedKeyType")}:{" "}
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error' </span>
? 'text-destructive' <span
: 'text-green-600' className={`font-medium ${
}`}> detectedPublicKeyType === "invalid" ||
{getFriendlyKeyTypeName(detectedPublicKeyType)} detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span> </span>
{publicKeyDetectionLoading && ( {publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span> <span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)} )}
</div> </div>
)} )}

View File

@@ -86,7 +86,8 @@ export function CredentialsManager({
const [editingFolderName, setEditingFolderName] = useState(""); const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false); const [operationLoading, setOperationLoading] = useState(false);
const [showDeployDialog, setShowDeployDialog] = useState(false); const [showDeployDialog, setShowDeployDialog] = useState(false);
const [deployingCredential, setDeployingCredential] = useState<Credential | null>(null); const [deployingCredential, setDeployingCredential] =
useState<Credential | null>(null);
const [availableHosts, setAvailableHosts] = useState<any[]>([]); const [availableHosts, setAvailableHosts] = useState<any[]>([]);
const [selectedHostId, setSelectedHostId] = useState<string>(""); const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false); const [deployLoading, setDeployLoading] = useState(false);
@@ -102,7 +103,7 @@ export function CredentialsManager({
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
setAvailableHosts(hosts); setAvailableHosts(hosts);
} catch (err) { } catch (err) {
console.error('Failed to fetch hosts:', err); console.error("Failed to fetch hosts:", err);
} }
}; };
@@ -126,7 +127,7 @@ export function CredentialsManager({
}; };
const handleDeploy = (credential: Credential) => { const handleDeploy = (credential: Credential) => {
if (credential.authType !== 'key') { if (credential.authType !== "key") {
toast.error("Only SSH key-based credentials can be deployed"); toast.error("Only SSH key-based credentials can be deployed");
return; return;
} }
@@ -149,7 +150,7 @@ export function CredentialsManager({
try { try {
const result = await deployCredentialToHost( const result = await deployCredentialToHost(
deployingCredential.id, deployingCredential.id,
parseInt(selectedHostId) parseInt(selectedHostId),
); );
if (result.success) { if (result.success) {
@@ -161,7 +162,7 @@ export function CredentialsManager({
toast.error(result.error || "Deployment failed"); toast.error(result.error || "Deployment failed");
} }
} catch (error) { } catch (error) {
console.error('Deployment error:', error); console.error("Deployment error:", error);
toast.error("Failed to deploy SSH key"); toast.error("Failed to deploy SSH key");
} finally { } finally {
setDeployLoading(false); setDeployLoading(false);
@@ -655,7 +656,7 @@ export function CredentialsManager({
<p>Edit credential</p> <p>Edit credential</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{credential.authType === 'key' && ( {credential.authType === "key" && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -816,9 +817,12 @@ export function CredentialsManager({
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" /> <User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div> </div>
<div> <div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</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"> <div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.name || deployingCredential.username} {deployingCredential.name ||
deployingCredential.username}
</div> </div>
</div> </div>
</div> </div>
@@ -827,7 +831,9 @@ export function CredentialsManager({
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" /> <User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div> </div>
<div> <div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</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"> <div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.username} {deployingCredential.username}
</div> </div>
@@ -838,9 +844,11 @@ export function CredentialsManager({
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" /> <Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div> </div>
<div> <div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</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"> <div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.keyType || 'SSH Key'} {deployingCredential.keyType || "SSH Key"}
</div> </div>
</div> </div>
</div> </div>
@@ -887,8 +895,9 @@ export function CredentialsManager({
<div className="text-sm text-blue-800 dark:text-blue-200"> <div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Deployment Process</p> <p className="font-medium mb-1">Deployment Process</p>
<p className="text-blue-700 dark:text-blue-300"> <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 This will safely add the public key to the target host's
without overwriting existing keys. The operation is reversible. ~/.ssh/authorized_keys file without overwriting existing
keys. The operation is reversible.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -11,10 +11,5 @@ export function FileManager({
initialHost?: SSHHost | null; initialHost?: SSHHost | null;
onClose?: () => void; onClose?: () => void;
}): React.ReactElement { }): React.ReactElement {
return ( return <FileManagerModern initialHost={initialHost} onClose={onClose} />;
<FileManagerModern
initialHost={initialHost}
onClose={onClose}
/>
);
} }

View File

@@ -18,7 +18,7 @@ import {
Terminal, Terminal,
Play, Play,
Star, Star,
Bookmark Bookmark,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -99,7 +99,7 @@ export function FileManagerContextMenu({
onUnpinFile, onUnpinFile,
onAddShortcut, onAddShortcut,
isPinned, isPinned,
currentPath currentPath,
}: ContextMenuProps) { }: ContextMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y }); const [menuPosition, setMenuPosition] = useState({ x, y });
@@ -138,7 +138,7 @@ export function FileManagerContextMenu({
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
// 检查点击是否在菜单内部 // 检查点击是否在菜单内部
const target = event.target as Element; const target = event.target as Element;
const menuElement = document.querySelector('[data-context-menu]'); const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) { if (!menuElement?.contains(target)) {
onClose(); onClose();
@@ -153,7 +153,7 @@ export function FileManagerContextMenu({
// 键盘支持 // 键盘支持
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
onClose(); onClose();
} }
@@ -169,19 +169,19 @@ export function FileManagerContextMenu({
onClose(); onClose();
}; };
document.addEventListener('mousedown', handleClickOutside, true); document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener('contextmenu', handleRightClick); document.addEventListener("contextmenu", handleRightClick);
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
window.addEventListener('blur', handleBlur); window.addEventListener("blur", handleBlur);
window.addEventListener('scroll', handleScroll, true); window.addEventListener("scroll", handleScroll, true);
// 设置清理函数 // 设置清理函数
cleanupFn = () => { cleanupFn = () => {
document.removeEventListener('mousedown', handleClickOutside, true); document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener('contextmenu', handleRightClick); document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('blur', handleBlur); window.removeEventListener("blur", handleBlur);
window.removeEventListener('scroll', handleScroll, true); window.removeEventListener("scroll", handleScroll, true);
}; };
}, 50); // 50ms延迟确保不会捕获到触发菜单的点击 }, 50); // 50ms延迟确保不会捕获到触发菜单的点击
@@ -198,9 +198,11 @@ export function FileManagerContextMenu({
const isFileContext = files.length > 0; const isFileContext = files.length > 0;
const isSingleFile = files.length === 1; const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1; const isMultipleFiles = files.length > 1;
const hasFiles = files.some(f => f.type === 'file'); const hasFiles = files.some((f) => f.type === "file");
const hasDirectories = files.some(f => f.type === 'directory'); const hasDirectories = files.some((f) => f.type === "directory");
const hasExecutableFiles = files.some(f => f.type === 'file' && f.executable); const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
// 构建菜单项 // 构建菜单项
const menuItems: MenuItem[] = []; const menuItems: MenuItem[] = [];
@@ -211,14 +213,19 @@ export function FileManagerContextMenu({
// 打开终端功能 - 支持文件和文件夹 // 打开终端功能 - 支持文件和文件夹
if (onOpenTerminal) { if (onOpenTerminal) {
const targetPath = isSingleFile const targetPath = isSingleFile
? (files[0].type === 'directory' ? files[0].path : files[0].path.substring(0, files[0].path.lastIndexOf('/'))) ? files[0].type === "directory"
: files[0].path.substring(0, files[0].path.lastIndexOf('/')); ? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({ menuItems.push({
icon: <Terminal className="w-4 h-4" />, icon: <Terminal className="w-4 h-4" />,
label: files[0].type === 'directory' ? t("fileManager.openTerminalInFolder") : t("fileManager.openTerminalInFileLocation"), label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath), action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+T" shortcut: "Ctrl+T",
}); });
} }
@@ -228,23 +235,29 @@ export function FileManagerContextMenu({
icon: <Play className="w-4 h-4" />, icon: <Play className="w-4 h-4" />,
label: t("fileManager.run"), label: t("fileManager.run"),
action: () => onRunExecutable(files[0]), action: () => onRunExecutable(files[0]),
shortcut: "Enter" shortcut: "Enter",
}); });
} }
if ((onOpenTerminal || (isSingleFile && hasExecutableFiles && onRunExecutable))) { // 添加分隔符(如果有上述功能)
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
} }
// 预览功能
if (hasFiles && onPreview) { if (hasFiles && onPreview) {
menuItems.push({ menuItems.push({
icon: <Eye className="w-4 h-4" />, icon: <Eye className="w-4 h-4" />,
label: t("fileManager.preview"), label: t("fileManager.preview"),
action: () => onPreview(files[0]), action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== 'file' disabled: !isSingleFile || files[0].type !== "file",
}); });
} }
// 下载功能
if (hasFiles && onDownload) { if (hasFiles && onDownload) {
menuItems.push({ menuItems.push({
icon: <Download className="w-4 h-4" />, icon: <Download className="w-4 h-4" />,
@@ -252,62 +265,75 @@ export function FileManagerContextMenu({
? t("fileManager.downloadFiles", { count: files.length }) ? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"), : t("fileManager.downloadFile"),
action: () => onDownload(files), action: () => onDownload(files),
shortcut: "Ctrl+D" shortcut: "Ctrl+D",
}); });
} }
// 拖拽到桌面菜单项(支持浏览器和桌面应用) // 拖拽到桌面菜单项(支持浏览器和桌面应用)
if (hasFiles && onDragToDesktop) { if (hasFiles && onDragToDesktop) {
const isModernBrowser = 'showSaveFilePicker' in window; const isModernBrowser = "showSaveFilePicker" in window;
menuItems.push({ menuItems.push({
icon: <ExternalLink className="w-4 h-4" />, icon: <ExternalLink className="w-4 h-4" />,
label: isMultipleFiles label: isMultipleFiles
? t("fileManager.saveFilesToSystem", { count: files.length }) ? t("fileManager.saveFilesToSystem", { count: files.length })
: t("fileManager.saveToSystem"), : t("fileManager.saveToSystem"),
action: () => onDragToDesktop(), action: () => onDragToDesktop(),
shortcut: isModernBrowser ? t("fileManager.selectLocationToSave") : t("fileManager.downloadToDefaultLocation") shortcut: isModernBrowser
? t("fileManager.selectLocationToSave")
: t("fileManager.downloadToDefaultLocation"),
}); });
} }
// PIN/UNPIN 功能 - 仅对单个文件显示 // PIN/UNPIN 功能 - 仅对单个文件显示
if (isSingleFile && files[0].type === 'file') { if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) { if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({ menuItems.push({
icon: <Star className="w-4 h-4 fill-yellow-400" />, icon: <Star className="w-4 h-4 fill-yellow-400" />,
label: t("fileManager.unpinFile"), label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]) action: () => onUnpinFile(files[0]),
}); });
} else if (!isCurrentlyPinned && onPinFile) { } else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({ menuItems.push({
icon: <Star className="w-4 h-4" />, icon: <Star className="w-4 h-4" />,
label: t("fileManager.pinFile"), label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]) action: () => onPinFile(files[0]),
}); });
} }
} }
// 添加文件夹快捷方式 - 仅对单个文件夹显示 // 添加文件夹快捷方式 - 仅对单个文件夹显示
if (isSingleFile && files[0].type === 'directory' && onAddShortcut) { if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({ menuItems.push({
icon: <Bookmark className="w-4 h-4" />, icon: <Bookmark className="w-4 h-4" />,
label: t("fileManager.addToShortcuts"), label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path) action: () => onAddShortcut(files[0].path),
}); });
} }
// 添加分隔符(如果有上述功能)
if (
(hasFiles && (onPreview || onDownload || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
}
// 重命名功能
if (isSingleFile && onRename) { if (isSingleFile && onRename) {
menuItems.push({ menuItems.push({
icon: <Edit3 className="w-4 h-4" />, icon: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"), label: t("fileManager.rename"),
action: () => onRename(files[0]), action: () => onRename(files[0]),
shortcut: "F2" shortcut: "F2",
}); });
} }
// 复制功能
if (onCopy) { if (onCopy) {
menuItems.push({ menuItems.push({
icon: <Copy className="w-4 h-4" />, icon: <Copy className="w-4 h-4" />,
@@ -315,10 +341,11 @@ export function FileManagerContextMenu({
? t("fileManager.copyFiles", { count: files.length }) ? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"), : t("fileManager.copy"),
action: () => onCopy(files), action: () => onCopy(files),
shortcut: "Ctrl+C" shortcut: "Ctrl+C",
}); });
} }
// 剪切功能
if (onCut) { if (onCut) {
menuItems.push({ menuItems.push({
icon: <Scissors className="w-4 h-4" />, icon: <Scissors className="w-4 h-4" />,
@@ -326,12 +353,16 @@ export function FileManagerContextMenu({
? t("fileManager.cutFiles", { count: files.length }) ? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"), : t("fileManager.cut"),
action: () => onCut(files), action: () => onCut(files),
shortcut: "Ctrl+X" shortcut: "Ctrl+X",
}); });
} }
// 添加分隔符(如果有编辑功能)
if ((isSingleFile && onRename) || onCopy || onCut) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
}
// 删除功能
if (onDelete) { if (onDelete) {
menuItems.push({ menuItems.push({
icon: <Trash2 className="w-4 h-4" />, icon: <Trash2 className="w-4 h-4" />,
@@ -340,17 +371,21 @@ export function FileManagerContextMenu({
: t("fileManager.delete"), : t("fileManager.delete"),
action: () => onDelete(files), action: () => onDelete(files),
shortcut: "Delete", shortcut: "Delete",
danger: true danger: true,
}); });
} }
// 添加分隔符(如果有删除功能)
if (onDelete) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
}
// 属性功能
if (isSingleFile && onProperties) { if (isSingleFile && onProperties) {
menuItems.push({ menuItems.push({
icon: <Info className="w-4 h-4" />, icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"), label: t("fileManager.properties"),
action: () => onProperties(files[0]) action: () => onProperties(files[0]),
}); });
} }
} else { } else {
@@ -362,62 +397,93 @@ export function FileManagerContextMenu({
icon: <Terminal className="w-4 h-4" />, icon: <Terminal className="w-4 h-4" />,
label: t("fileManager.openTerminalHere"), label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath), action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+T" shortcut: "Ctrl+T",
}); });
menuItems.push({ separator: true } as MenuItem);
} }
// 上传功能
if (onUpload) { if (onUpload) {
menuItems.push({ menuItems.push({
icon: <Upload className="w-4 h-4" />, icon: <Upload className="w-4 h-4" />,
label: t("fileManager.uploadFile"), label: t("fileManager.uploadFile"),
action: onUpload, action: onUpload,
shortcut: "Ctrl+U" shortcut: "Ctrl+U",
}); });
} }
// 添加分隔符(如果有终端或上传功能)
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
}
// 新建文件夹
if (onNewFolder) { if (onNewFolder) {
menuItems.push({ menuItems.push({
icon: <FolderPlus className="w-4 h-4" />, icon: <FolderPlus className="w-4 h-4" />,
label: t("fileManager.newFolder"), label: t("fileManager.newFolder"),
action: onNewFolder, action: onNewFolder,
shortcut: "Ctrl+Shift+N" shortcut: "Ctrl+Shift+N",
}); });
} }
// 新建文件
if (onNewFile) { if (onNewFile) {
menuItems.push({ menuItems.push({
icon: <FilePlus className="w-4 h-4" />, icon: <FilePlus className="w-4 h-4" />,
label: t("fileManager.newFile"), label: t("fileManager.newFile"),
action: onNewFile, action: onNewFile,
shortcut: "Ctrl+N" shortcut: "Ctrl+N",
}); });
} }
// 添加分隔符(如果有新建功能)
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem); menuItems.push({ separator: true } as MenuItem);
}
// 刷新功能
if (onRefresh) { if (onRefresh) {
menuItems.push({ menuItems.push({
icon: <RefreshCw className="w-4 h-4" />, icon: <RefreshCw className="w-4 h-4" />,
label: t("fileManager.refresh"), label: t("fileManager.refresh"),
action: onRefresh, action: onRefresh,
shortcut: "F5" shortcut: "F5",
}); });
} }
// 粘贴功能
if (hasClipboard && onPaste) { if (hasClipboard && onPaste) {
menuItems.push({ menuItems.push({
icon: <Clipboard className="w-4 h-4" />, icon: <Clipboard className="w-4 h-4" />,
label: t("fileManager.paste"), label: t("fileManager.paste"),
action: onPaste, action: onPaste,
shortcut: "Ctrl+V" shortcut: "Ctrl+V",
}); });
} }
} }
// 过滤掉连续的分隔符
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
// 如果是分隔符,检查前一个和后一个是否也是分隔符
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
// 如果前一个或后一个是分隔符,则过滤掉当前分隔符
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
// 移除开头和结尾的分隔符
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
return ( return (
<> <>
{/* 透明遮罩层用于捕获点击事件 */} {/* 透明遮罩层用于捕获点击事件 */}
@@ -426,18 +492,18 @@ export function FileManagerContextMenu({
{/* 菜单本体 */} {/* 菜单本体 */}
<div <div
data-context-menu 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" className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-50 overflow-hidden"
style={{ style={{
left: menuPosition.x, left: menuPosition.x,
top: menuPosition.y top: menuPosition.y,
}} }}
> >
{menuItems.map((item, index) => { {finalMenuItems.map((item, index) => {
if (item.separator) { if (item.separator) {
return ( return (
<div <div
key={`separator-${index}`} key={`separator-${index}`}
className="border-t border-dark-border my-1" className="border-t border-dark-border"
/> />
); );
} }
@@ -448,8 +514,9 @@ export function FileManagerContextMenu({
className={cn( className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between", "w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors", "hover:bg-dark-hover transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
item.disabled && "opacity-50 cursor-not-allowed", item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10" item.danger && "text-red-400 hover:bg-red-500/10",
)} )}
onClick={() => { onClick={() => {
if (!item.disabled) { if (!item.disabled) {
@@ -459,12 +526,12 @@ export function FileManagerContextMenu({
}} }}
disabled={item.disabled} disabled={item.disabled}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-1 min-w-0">
{item.icon} <div className="flex-shrink-0">{item.icon}</div>
<span>{item.label}</span> <span className="flex-1">{item.label}</span>
</div> </div>
{item.shortcut && ( {item.shortcut && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{item.shortcut} {item.shortcut}
</span> </span>
)} )}

File diff suppressed because it is too large Load Diff

View File

@@ -422,7 +422,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
} }
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath); const symlinkInfo = await identifySSHSymlink(
currentSessionId,
symlinkPath,
);
if (symlinkInfo.type === "directory") { if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it // If symlink points to a directory, navigate to it
@@ -590,7 +593,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"

File diff suppressed because it is too large Load Diff

View File

@@ -86,18 +86,21 @@ export function FileManagerOperations({
reader.onerror = () => reject(reader.error); reader.onerror = () => reject(reader.error);
// 检查文件类型,决定读取方式 // 检查文件类型,决定读取方式
const isTextFile = uploadFile.type.startsWith('text/') || const isTextFile =
uploadFile.type === 'application/json' || uploadFile.type.startsWith("text/") ||
uploadFile.type === 'application/javascript' || uploadFile.type === "application/json" ||
uploadFile.type === 'application/xml' || uploadFile.type === "application/javascript" ||
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); 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) { if (isTextFile) {
reader.onload = () => { reader.onload = () => {
if (reader.result) { if (reader.result) {
resolve(reader.result as string); resolve(reader.result as string);
} else { } else {
reject(new Error('Failed to read text file content')); reject(new Error("Failed to read text file content"));
} }
}; };
reader.readAsText(uploadFile); reader.readAsText(uploadFile);
@@ -105,14 +108,14 @@ export function FileManagerOperations({
reader.onload = () => { reader.onload = () => {
if (reader.result instanceof ArrayBuffer) { if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result); const bytes = new Uint8Array(reader.result);
let binary = ''; let binary = "";
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]); binary += String.fromCharCode(bytes[i]);
} }
const base64 = btoa(binary); const base64 = btoa(binary);
resolve(base64); resolve(base64);
} else { } else {
reject(new Error('Failed to read binary file')); reject(new Error("Failed to read binary file"));
} }
}; };
reader.readAsArrayBuffer(uploadFile); reader.readAsArrayBuffer(uploadFile);
@@ -201,7 +204,7 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const { toast } = await import("sonner"); const { toast } = await import("sonner");
const fileName = downloadPath.split('/').pop() || 'download'; const fileName = downloadPath.split("/").pop() || "download";
const loadingToast = toast.loading( const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }), t("fileManager.downloadingFile", { name: fileName }),
); );
@@ -209,10 +212,7 @@ export function FileManagerOperations({
try { try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts"); const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile( const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
sshSessionId,
downloadPath.trim(),
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
@@ -224,11 +224,13 @@ export function FileManagerOperations({
byteNumbers[i] = byteCharacters.charCodeAt(i); byteNumbers[i] = byteCharacters.charCodeAt(i);
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' }); const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
// Create download link // Create download link
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = response.fileName || fileName; link.download = response.fileName || fileName;
document.body.appendChild(link); document.body.appendChild(link);
@@ -237,7 +239,9 @@ export function FileManagerOperations({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
onSuccess( onSuccess(
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }), t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || fileName,
}),
); );
} else { } else {
onError(t("fileManager.noFileContent")); onError(t("fileManager.noFileContent"));

View File

@@ -8,10 +8,10 @@ import {
Star, Star,
Clock, Clock,
Bookmark, Bookmark,
FolderOpen FolderOpen,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost } from "../../../types/index.js"; import type { SSHHost } from "@/types/index";
import { import {
getRecentFiles, getRecentFiles,
getPinnedFiles, getPinnedFiles,
@@ -19,7 +19,7 @@ import {
listSSHFiles, listSSHFiles,
removeRecentFile, removeRecentFile,
removePinnedFile, removePinnedFile,
removeFolderShortcut removeFolderShortcut,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -27,7 +27,7 @@ export interface SidebarItem {
id: string; id: string;
name: string; name: string;
path: string; path: string;
type: 'recent' | 'pinned' | 'shortcut' | 'folder'; type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string; lastAccessed?: string;
isExpanded?: boolean; isExpanded?: boolean;
children?: SidebarItem[]; children?: SidebarItem[];
@@ -50,14 +50,16 @@ export function FileManagerSidebar({
onLoadDirectory, onLoadDirectory,
onFileOpen, onFileOpen,
sshSessionId, sshSessionId,
refreshTrigger refreshTrigger,
}: FileManagerSidebarProps) { }: FileManagerSidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]); const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]); const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]); const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]); const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['root'])); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["root"]),
);
// 右键菜单状态 // 右键菜单状态
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@@ -69,7 +71,7 @@ export function FileManagerSidebar({
x: 0, x: 0,
y: 0, y: 0,
isVisible: false, isVisible: false,
item: null item: null,
}); });
// 加载快捷功能数据 // 加载快捷功能数据
@@ -94,8 +96,8 @@ export function FileManagerSidebar({
id: `recent-${item.id}`, id: `recent-${item.id}`,
name: item.name, name: item.name,
path: item.path, path: item.path,
type: 'recent' as const, type: "recent" as const,
lastAccessed: item.lastOpened lastAccessed: item.lastOpened,
})); }));
setRecentItems(recentItems); setRecentItems(recentItems);
@@ -105,7 +107,7 @@ export function FileManagerSidebar({
id: `pinned-${item.id}`, id: `pinned-${item.id}`,
name: item.name, name: item.name,
path: item.path, path: item.path,
type: 'pinned' as const type: "pinned" as const,
})); }));
setPinnedItems(pinnedItems); setPinnedItems(pinnedItems);
@@ -115,11 +117,11 @@ export function FileManagerSidebar({
id: `shortcut-${item.id}`, id: `shortcut-${item.id}`,
name: item.name, name: item.name,
path: item.path, path: item.path,
type: 'shortcut' as const type: "shortcut" as const,
})); }));
setShortcuts(shortcutItems); setShortcuts(shortcutItems);
} catch (error) { } catch (error) {
console.error('Failed to load quick access data:', error); console.error("Failed to load quick access data:", error);
// 如果加载失败,保持空数组 // 如果加载失败,保持空数组
setRecentItems([]); setRecentItems([]);
setPinnedItems([]); setPinnedItems([]);
@@ -134,9 +136,11 @@ export function FileManagerSidebar({
try { try {
await removeRecentFile(currentHost.id, item.path); await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData(); // 重新加载数据 loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.removedFromRecentFiles", { name: item.name })); toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) { } catch (error) {
console.error('Failed to remove recent file:', error); console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed")); toast.error(t("fileManager.removeFailed"));
} }
}; };
@@ -149,7 +153,7 @@ export function FileManagerSidebar({
loadQuickAccessData(); // 重新加载数据 loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name })); toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) { } catch (error) {
console.error('Failed to unpin file:', error); console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed")); toast.error(t("fileManager.unpinFailed"));
} }
}; };
@@ -162,7 +166,7 @@ export function FileManagerSidebar({
loadQuickAccessData(); // 重新加载数据 loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.removedShortcut", { name: item.name })); toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) { } catch (error) {
console.error('Failed to remove shortcut:', error); console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed")); toast.error(t("fileManager.removeShortcutFailed"));
} }
}; };
@@ -173,12 +177,12 @@ export function FileManagerSidebar({
try { try {
// 批量删除所有recent文件 // 批量删除所有recent文件
await Promise.all( await Promise.all(
recentItems.map(item => removeRecentFile(currentHost.id, item.path)) recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
); );
loadQuickAccessData(); // 重新加载数据 loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.clearedAllRecentFiles")); toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) { } catch (error) {
console.error('Failed to clear recent files:', error); console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed")); toast.error(t("fileManager.clearFailed"));
} }
}; };
@@ -192,12 +196,12 @@ export function FileManagerSidebar({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
isVisible: true, isVisible: true,
item item,
}); });
}; };
const closeContextMenu = () => { const closeContextMenu = () => {
setContextMenu(prev => ({ ...prev, isVisible: false, item: null })); setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
}; };
// 点击外部关闭菜单 // 点击外部关闭菜单
@@ -206,7 +210,7 @@ export function FileManagerSidebar({
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element; const target = event.target as Element;
const menuElement = document.querySelector('[data-sidebar-context-menu]'); const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) { if (!menuElement?.contains(target)) {
closeContextMenu(); closeContextMenu();
@@ -214,21 +218,21 @@ export function FileManagerSidebar({
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === "Escape") {
closeContextMenu(); closeContextMenu();
} }
}; };
// 延迟添加监听器,避免立即触发 // 延迟添加监听器,避免立即触发
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
}, 50); }, 50);
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [contextMenu.isVisible]); }, [contextMenu.isVisible]);
@@ -237,61 +241,64 @@ export function FileManagerSidebar({
try { try {
// 加载根目录 // 加载根目录
const response = await listSSHFiles(sshSessionId, '/'); const response = await listSSHFiles(sshSessionId, "/");
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式 // listSSHFiles 现在总是返回 {files: Array, path: string} 格式
const rootFiles = response.files || []; const rootFiles = response.files || [];
const rootFolders = rootFiles.filter((item: any) => item.type === 'directory'); const rootFolders = rootFiles.filter(
(item: any) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: any) => ({ const rootTreeItems = rootFolders.map((folder: any) => ({
id: `folder-${folder.name}`, id: `folder-${folder.name}`,
name: folder.name, name: folder.name,
path: folder.path, path: folder.path,
type: 'folder' as const, type: "folder" as const,
isExpanded: false, isExpanded: false,
children: [] // 子目录将按需加载 children: [], // 子目录将按需加载
})); }));
setDirectoryTree([ setDirectoryTree([
{ {
id: 'root', id: "root",
name: '/', name: "/",
path: '/', path: "/",
type: 'folder' as const, type: "folder" as const,
isExpanded: true, isExpanded: true,
children: rootTreeItems children: rootTreeItems,
} },
]); ]);
} catch (error) { } catch (error) {
console.error('Failed to load directory tree:', error); console.error("Failed to load directory tree:", error);
// 如果加载失败,显示简单的根目录 // 如果加载失败,显示简单的根目录
setDirectoryTree([ setDirectoryTree([
{ {
id: 'root', id: "root",
name: '/', name: "/",
path: '/', path: "/",
type: 'folder' as const, type: "folder" as const,
isExpanded: false, isExpanded: false,
children: [] children: [],
} },
]); ]);
} }
}; };
const handleItemClick = (item: SidebarItem) => { const handleItemClick = (item: SidebarItem) => {
if (item.type === 'folder') { if (item.type === "folder") {
toggleFolder(item.id, item.path); toggleFolder(item.id, item.path);
onPathChange(item.path); onPathChange(item.path);
} else if (item.type === 'recent' || item.type === 'pinned') { } else if (item.type === "recent" || item.type === "pinned") {
// 对于文件类型,调用文件打开回调 // 对于文件类型,调用文件打开回调
if (onFileOpen) { if (onFileOpen) {
onFileOpen(item); onFileOpen(item);
} else { } else {
// 如果没有文件打开回调,切换到文件所在目录 // 如果没有文件打开回调,切换到文件所在目录
const directory = item.path.substring(0, item.path.lastIndexOf('/')) || '/'; const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory); onPathChange(directory);
} }
} else if (item.type === 'shortcut') { } else if (item.type === "shortcut") {
// 文件夹快捷方式直接切换到目录 // 文件夹快捷方式直接切换到目录
onPathChange(item.path); onPathChange(item.path);
} }
@@ -306,27 +313,29 @@ export function FileManagerSidebar({
newExpanded.add(folderId); newExpanded.add(folderId);
// 按需加载子目录 // 按需加载子目录
if (sshSessionId && folderPath && folderPath !== '/') { if (sshSessionId && folderPath && folderPath !== "/") {
try { try {
const subResponse = await listSSHFiles(sshSessionId, folderPath); const subResponse = await listSSHFiles(sshSessionId, folderPath);
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式 // listSSHFiles 现在总是返回 {files: Array, path: string} 格式
const subFiles = subResponse.files || []; const subFiles = subResponse.files || [];
const subFolders = subFiles.filter((item: any) => item.type === 'directory'); const subFolders = subFiles.filter(
(item: any) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: any) => ({ const subTreeItems = subFolders.map((folder: any) => ({
id: `folder-${folder.path.replace(/\//g, '-')}`, id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name, name: folder.name,
path: folder.path, path: folder.path,
type: 'folder' as const, type: "folder" as const,
isExpanded: false, isExpanded: false,
children: [] children: [],
})); }));
// 更新目录树,为当前文件夹添加子目录 // 更新目录树,为当前文件夹添加子目录
setDirectoryTree(prevTree => { setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => { const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map(item => { return items.map((item) => {
if (item.id === folderId) { if (item.id === folderId) {
return { ...item, children: subTreeItems }; return { ...item, children: subTreeItems };
} else if (item.children) { } else if (item.children) {
@@ -338,7 +347,7 @@ export function FileManagerSidebar({
return updateChildren(prevTree); return updateChildren(prevTree);
}); });
} catch (error) { } catch (error) {
console.error('Failed to load subdirectory:', error); console.error("Failed to load subdirectory:", error);
} }
} }
} }
@@ -354,20 +363,24 @@ export function FileManagerSidebar({
<div key={item.id}> <div key={item.id}>
<div <div
className={cn( className={cn(
"flex items-center gap-2 px-2 py-1 text-sm cursor-pointer hover:bg-dark-hover rounded", "flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-dark-hover rounded",
isActive && "bg-primary/20 text-primary", isActive && "bg-primary/20 text-primary",
"text-white" "text-white",
)} )}
style={{ paddingLeft: `${8 + level * 16}px` }} style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
onContextMenu={(e) => { onContextMenu={(e) => {
// 只有快捷功能项才需要右键菜单 // 只有快捷功能项才需要右键菜单
if (item.type === 'recent' || item.type === 'pinned' || item.type === 'shortcut') { if (
item.type === "recent" ||
item.type === "pinned" ||
item.type === "shortcut"
) {
handleContextMenu(e, item); handleContextMenu(e, item);
} }
}} }}
> >
{item.type === 'folder' && ( {item.type === "folder" && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -383,8 +396,12 @@ export function FileManagerSidebar({
</button> </button>
)} )}
{item.type === 'folder' ? ( {item.type === "folder" ? (
isExpanded ? <FolderOpen className="w-4 h-4" /> : <Folder className="w-4 h-4" /> isExpanded ? (
<FolderOpen className="w-4 h-4" />
) : (
<Folder className="w-4 h-4" />
)
) : ( ) : (
<File className="w-4 h-4" /> <File className="w-4 h-4" />
)} )}
@@ -392,7 +409,7 @@ export function FileManagerSidebar({
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
</div> </div>
{item.type === 'folder' && isExpanded && item.children && ( {item.type === "folder" && isExpanded && item.children && (
<div> <div>
{item.children.map((child) => renderSidebarItem(child, level + 1))} {item.children.map((child) => renderSidebarItem(child, level + 1))}
</div> </div>
@@ -401,12 +418,16 @@ export function FileManagerSidebar({
); );
}; };
const renderSection = (title: string, icon: React.ReactNode, items: SidebarItem[]) => { const renderSection = (
title: string,
icon: React.ReactNode,
items: SidebarItem[],
) => {
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<div className="mb-4"> <div className="mb-5">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{icon} {icon}
{title} {title}
</div> </div>
@@ -417,19 +438,39 @@ export function FileManagerSidebar({
); );
}; };
// Check if there are any quick access items
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
return ( return (
<> <>
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border"> <div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<div className="absolute inset-0 overflow-y-auto thin-scrollbar p-2 space-y-4"> <div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{/* 快捷功能区域 */} {/* 快捷功能区域 */}
{renderSection(t("fileManager.recent"), <Clock className="w-3 h-3" />, recentItems)} {renderSection(
{renderSection(t("fileManager.pinned"), <Star className="w-3 h-3" />, pinnedItems)} t("fileManager.recent"),
{renderSection(t("fileManager.folderShortcuts"), <Bookmark className="w-3 h-3" />, shortcuts)} <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
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider"> className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
)}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" /> <Folder className="w-3 h-3" />
{t("fileManager.directories")} {t("fileManager.directories")}
</div> </div>
@@ -447,65 +488,79 @@ export function FileManagerSidebar({
<div className="fixed inset-0 z-40" /> <div className="fixed inset-0 z-40" />
<div <div
data-sidebar-context-menu data-sidebar-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl py-1 min-w-[160px] z-50" className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{ style={{
left: contextMenu.x, left: contextMenu.x,
top: contextMenu.y top: contextMenu.y,
}} }}
> >
{contextMenu.item.type === 'recent' && ( {contextMenu.item.type === "recent" && (
<> <>
<button <button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white" className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => { onClick={() => {
handleRemoveRecentFile(contextMenu.item!); handleRemoveRecentFile(contextMenu.item!);
closeContextMenu(); closeContextMenu();
}} }}
> >
<div className="flex-shrink-0">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{t("fileManager.removeFromRecentFiles")}</span> </div>
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button> </button>
{recentItems.length > 1 && ( {recentItems.length > 1 && (
<> <>
<div className="border-t border-dark-border my-1" /> <div className="border-t border-dark-border" />
<button <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" className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
onClick={() => { onClick={() => {
handleClearAllRecent(); handleClearAllRecent();
closeContextMenu(); closeContextMenu();
}} }}
> >
<div className="flex-shrink-0">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{t("fileManager.clearAllRecentFiles")}</span> </div>
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button> </button>
</> </>
)} )}
</> </>
)} )}
{contextMenu.item.type === 'pinned' && ( {contextMenu.item.type === "pinned" && (
<button <button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white" className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => { onClick={() => {
handleUnpinFile(contextMenu.item!); handleUnpinFile(contextMenu.item!);
closeContextMenu(); closeContextMenu();
}} }}
> >
<div className="flex-shrink-0">
<Star className="w-4 h-4" /> <Star className="w-4 h-4" />
<span>{t("fileManager.unpinFile")}</span> </div>
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button> </button>
)} )}
{contextMenu.item.type === 'shortcut' && ( {contextMenu.item.type === "shortcut" && (
<button <button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white" className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => { onClick={() => {
handleRemoveShortcut(contextMenu.item!); handleRemoveShortcut(contextMenu.item!);
closeContextMenu(); closeContextMenu();
}} }}
> >
<div className="flex-shrink-0">
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />
<span>{t("fileManager.removeShortcut")}</span> </div>
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button> </button>
)} )}
</div> </div>

View File

@@ -1,17 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { DiffEditor } from '@monaco-editor/react'; import { DiffEditor } from "@monaco-editor/react";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { toast } from 'sonner'; import { toast } from "sonner";
import { import {
Download, Download,
RefreshCw, RefreshCw,
Eye, Eye,
EyeOff, EyeOff,
ArrowLeftRight, ArrowLeftRight,
FileText FileText,
} from 'lucide-react'; } from "lucide-react";
import { readSSHFile, downloadSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios'; import {
import type { FileItem, SSHHost } from '../../../../types/index.js'; readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffViewerProps { interface DiffViewerProps {
file1: FileItem; file1: FileItem;
@@ -28,13 +33,15 @@ export function DiffViewer({
sshSessionId, sshSessionId,
sshHost, sshHost,
onDownload1, onDownload1,
onDownload2 onDownload2,
}: DiffViewerProps) { }: DiffViewerProps) {
const [content1, setContent1] = useState<string>(''); const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>(''); const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<'side-by-side' | 'inline'>('side-by-side'); const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true); const [showLineNumbers, setShowLineNumbers] = useState(true);
// 确保SSH连接有效 // 确保SSH连接有效
@@ -52,19 +59,19 @@ export function DiffViewer({
keyPassword: sshHost.keyPassword, keyPassword: sshHost.keyPassword,
authType: sshHost.authType, authType: sshHost.authType,
credentialId: sshHost.credentialId, credentialId: sshHost.credentialId,
userId: sshHost.userId userId: sshHost.userId,
}); });
} }
} catch (error) { } catch (error) {
console.error('SSH connection check/reconnect failed:', error); console.error("SSH connection check/reconnect failed:", error);
throw error; throw error;
} }
}; };
// 加载文件内容 // 加载文件内容
const loadFileContents = async () => { const loadFileContents = async () => {
if (file1.type !== 'file' || file2.type !== 'file') { if (file1.type !== "file" || file2.type !== "file") {
setError('只能对比文件类型的项目'); setError("只能对比文件类型的项目");
return; return;
} }
@@ -78,21 +85,28 @@ export function DiffViewer({
// 并行加载两个文件 // 并行加载两个文件
const [response1, response2] = await Promise.all([ const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path), readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path) readSSHFile(sshSessionId, file2.path),
]); ]);
setContent1(response1.content || ''); setContent1(response1.content || "");
setContent2(response2.content || ''); setContent2(response2.content || "");
} catch (error: any) { } catch (error: any) {
console.error('Failed to load files for diff:', error); console.error("Failed to load files for diff:", error);
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.tooLarge) { if (errorData?.tooLarge) {
setError(`文件过大: ${errorData.error}`); setError(`文件过大: ${errorData.error}`);
} else if (error.message?.includes('connection') || error.message?.includes('established')) { } else if (
setError(`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`); error.message?.includes("connection") ||
error.message?.includes("established")
) {
setError(
`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`,
);
} else { } else {
setError(`加载文件失败: ${error.message || errorData?.error || '未知错误'}`); setError(
`加载文件失败: ${error.message || errorData?.error || "未知错误"}`,
);
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -112,10 +126,12 @@ export function DiffViewer({
byteNumbers[i] = byteCharacters.charCodeAt(i); byteNumbers[i] = byteCharacters.charCodeAt(i);
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' }); const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = response.fileName || file.name; link.download = response.fileName || file.name;
document.body.appendChild(link); document.body.appendChild(link);
@@ -126,44 +142,44 @@ export function DiffViewer({
toast.success(`文件下载成功: ${file.name}`); toast.success(`文件下载成功: ${file.name}`);
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to download file:', error); console.error("Failed to download file:", error);
toast.error(`下载失败: ${error.message || '未知错误'}`); toast.error(`下载失败: ${error.message || "未知错误"}`);
} }
}; };
// 获取文件语言类型 // 获取文件语言类型
const getFileLanguage = (fileName: string): string => { const getFileLanguage = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
'js': 'javascript', js: "javascript",
'jsx': 'javascript', jsx: "javascript",
'ts': 'typescript', ts: "typescript",
'tsx': 'typescript', tsx: "typescript",
'py': 'python', py: "python",
'java': 'java', java: "java",
'c': 'c', c: "c",
'cpp': 'cpp', cpp: "cpp",
'cs': 'csharp', cs: "csharp",
'php': 'php', php: "php",
'rb': 'ruby', rb: "ruby",
'go': 'go', go: "go",
'rs': 'rust', rs: "rust",
'html': 'html', html: "html",
'css': 'css', css: "css",
'scss': 'scss', scss: "scss",
'less': 'less', less: "less",
'json': 'json', json: "json",
'xml': 'xml', xml: "xml",
'yaml': 'yaml', yaml: "yaml",
'yml': 'yaml', yml: "yaml",
'md': 'markdown', md: "markdown",
'sql': 'sql', sql: "sql",
'sh': 'shell', sh: "shell",
'bash': 'shell', bash: "shell",
'ps1': 'powershell', ps1: "powershell",
'dockerfile': 'dockerfile' dockerfile: "dockerfile",
}; };
return languageMap[ext || ''] || 'plaintext'; return languageMap[ext || ""] || "plaintext";
}; };
// 初始加载 // 初始加载
@@ -205,7 +221,9 @@ export function DiffViewer({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium text-green-400 mx-2">{file1.name}</span> <span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" /> <ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span> <span className="font-medium text-blue-400">{file2.name}</span>
</div> </div>
@@ -216,9 +234,13 @@ export function DiffViewer({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setDiffMode(diffMode === 'side-by-side' ? 'inline' : 'side-by-side')} onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
> >
{diffMode === 'side-by-side' ? '并排' : '内联'} {diffMode === "side-by-side" ? "并排" : "内联"}
</Button> </Button>
{/* 行号切换 */} {/* 行号切换 */}
@@ -227,7 +249,11 @@ export function DiffViewer({
size="sm" size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)} onClick={() => setShowLineNumbers(!showLineNumbers)}
> >
{showLineNumbers ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />} {showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button> </Button>
{/* 下载按钮 */} {/* 下载按钮 */}
@@ -252,11 +278,7 @@ export function DiffViewer({
</Button> </Button>
{/* 刷新按钮 */} {/* 刷新按钮 */}
<Button <Button variant="outline" size="sm" onClick={loadFileContents}>
variant="outline"
size="sm"
onClick={loadFileContents}
>
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -271,22 +293,22 @@ export function DiffViewer({
language={getFileLanguage(file1.name)} language={getFileLanguage(file1.name)}
theme="vs-dark" theme="vs-dark"
options={{ options={{
renderSideBySide: diffMode === 'side-by-side', renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? 'on' : 'off', lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false }, minimap: { enabled: false },
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
fontSize: 13, fontSize: 13,
wordWrap: 'off', wordWrap: "off",
automaticLayout: true, automaticLayout: true,
readOnly: true, readOnly: true,
originalEditable: false, originalEditable: false,
modifiedEditable: false, modifiedEditable: false,
scrollbar: { scrollbar: {
vertical: 'visible', vertical: "visible",
horizontal: 'visible' horizontal: "visible",
}, },
diffWordWrap: 'off', diffWordWrap: "off",
ignoreTrimWhitespace: false ignoreTrimWhitespace: false,
}} }}
loading={ loading={
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from "react";
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from './DiffViewer'; import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from './WindowManager'; import { useWindowManager } from "./WindowManager";
import type { FileItem, SSHHost } from '../../../../types/index.js'; import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffWindowProps { interface DiffWindowProps {
windowId: string; windowId: string;
@@ -21,11 +21,12 @@ export function DiffWindow({
sshSessionId, sshSessionId,
sshHost, sshHost,
initialX = 150, initialX = 150,
initialY = 100 initialY = 100,
}: DiffWindowProps) { }: DiffWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager(); const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find(w => w.id === windowId); const currentWindow = windows.find((w) => w.id === windowId);
// 窗口操作处理 // 窗口操作处理
const handleClose = () => { const handleClose = () => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { Minus, Square, X, Maximize2, Minimize2 } from 'lucide-react'; import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
interface DraggableWindowProps { interface DraggableWindowProps {
title: string; title: string;
@@ -33,14 +33,17 @@ export function DraggableWindow({
onMaximize, onMaximize,
isMaximized = false, isMaximized = false,
zIndex = 1000, zIndex = 1000,
onFocus onFocus,
}: DraggableWindowProps) { }: DraggableWindowProps) {
// 窗口状态 // 窗口状态
const [position, setPosition] = useState({ x: initialX, y: initialY }); const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({ width: initialWidth, height: initialHeight }); const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>(''); const [resizeDirection, setResizeDirection] = useState<string>("");
// 拖拽开始位置 // 拖拽开始位置
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
@@ -55,7 +58,8 @@ export function DraggableWindow({
}, [onFocus]); }, [onFocus]);
// 拖拽处理 // 拖拽处理
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return; if (isMaximized) return;
e.preventDefault(); e.preventDefault();
@@ -63,16 +67,25 @@ export function DraggableWindow({
setDragStart({ x: e.clientX, y: e.clientY }); setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y }); setWindowStart({ x: position.x, y: position.y });
onFocus?.(); onFocus?.();
}, [isMaximized, position, onFocus]); },
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback((e: MouseEvent) => { const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) { if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x; const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y; const deltaY = e.clientY - dragStart.y;
setPosition({ setPosition({
x: Math.max(0, Math.min(window.innerWidth - size.width, windowStart.x + deltaX)), x: Math.max(
y: Math.max(0, Math.min(window.innerHeight - 40, windowStart.y + deltaY)) // 保持标题栏可见 0,
Math.min(window.innerWidth - size.width, windowStart.x + deltaX),
),
y: Math.max(
0,
Math.min(window.innerHeight - 40, windowStart.y + deltaY),
), // 保持标题栏可见
}); });
} }
@@ -85,34 +98,54 @@ export function DraggableWindow({
let newX = position.x; let newX = position.x;
let newY = position.y; let newY = position.y;
if (resizeDirection.includes('right')) { if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, windowStart.x + deltaX); newWidth = Math.max(minWidth, windowStart.x + deltaX);
} }
if (resizeDirection.includes('left')) { if (resizeDirection.includes("left")) {
newWidth = Math.max(minWidth, size.width - deltaX); newWidth = Math.max(minWidth, size.width - deltaX);
newX = Math.min(windowStart.x + deltaX, position.x + size.width - minWidth); newX = Math.min(
windowStart.x + deltaX,
position.x + size.width - minWidth,
);
} }
if (resizeDirection.includes('bottom')) { if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, windowStart.y + deltaY); newHeight = Math.max(minHeight, windowStart.y + deltaY);
} }
if (resizeDirection.includes('top')) { if (resizeDirection.includes("top")) {
newHeight = Math.max(minHeight, size.height - deltaY); newHeight = Math.max(minHeight, size.height - deltaY);
newY = Math.min(windowStart.y + deltaY, position.y + size.height - minHeight); newY = Math.min(
windowStart.y + deltaY,
position.y + size.height - minHeight,
);
} }
setSize({ width: newWidth, height: newHeight }); setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY }); setPosition({ x: newX, y: newY });
} }
}, [isDragging, isResizing, isMaximized, dragStart, windowStart, size, position, minWidth, minHeight, resizeDirection]); },
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
],
);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
setIsDragging(false); setIsDragging(false);
setIsResizing(false); setIsResizing(false);
setResizeDirection(''); setResizeDirection("");
}, []); }, []);
// 调整大小处理 // 调整大小处理
const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => { const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return; if (isMaximized) return;
e.preventDefault(); e.preventDefault();
@@ -122,21 +155,23 @@ export function DraggableWindow({
setDragStart({ x: e.clientX, y: e.clientY }); setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: size.width, y: size.height }); setWindowStart({ x: size.width, y: size.height });
onFocus?.(); onFocus?.();
}, [isMaximized, size, onFocus]); },
[isMaximized, size, onFocus],
);
// 全局事件监听 // 全局事件监听
useEffect(() => { useEffect(() => {
if (isDragging || isResizing) { if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = 'none'; document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? 'grabbing' : 'resizing'; document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = ''; document.body.style.userSelect = "";
document.body.style.cursor = ''; document.body.style.cursor = "";
}; };
} }
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]); }, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
@@ -152,14 +187,14 @@ export function DraggableWindow({
className={cn( className={cn(
"absolute bg-card border border-border rounded-lg shadow-2xl", "absolute bg-card border border-border rounded-lg shadow-2xl",
"select-none overflow-hidden", "select-none overflow-hidden",
isMaximized ? "inset-0" : "" isMaximized ? "inset-0" : "",
)} )}
style={{ style={{
left: isMaximized ? 0 : position.x, left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y, top: isMaximized ? 0 : position.y,
width: isMaximized ? '100%' : size.width, width: isMaximized ? "100%" : size.width,
height: isMaximized ? '100%' : size.height, height: isMaximized ? "100%" : size.height,
zIndex zIndex,
}} }}
onClick={handleWindowClick} onClick={handleWindowClick}
> >
@@ -169,7 +204,7 @@ export function DraggableWindow({
className={cn( className={cn(
"flex items-center justify-between px-3 py-2", "flex items-center justify-between px-3 py-2",
"bg-muted/50 text-foreground border-b border-border", "bg-muted/50 text-foreground border-b border-border",
"cursor-grab active:cursor-grabbing" "cursor-grab active:cursor-grabbing",
)} )}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick} onDoubleClick={handleTitleDoubleClick}
@@ -223,7 +258,10 @@ export function DraggableWindow({
</div> </div>
{/* 窗口内容 */} {/* 窗口内容 */}
<div className="flex-1 overflow-auto" style={{ height: 'calc(100% - 40px)' }}> <div
className="flex-1 overflow-auto"
style={{ height: "calc(100% - 40px)" }}
>
{children} {children}
</div> </div>
@@ -233,37 +271,37 @@ export function DraggableWindow({
{/* 边缘调整 */} {/* 边缘调整 */}
<div <div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize" className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, 'top')} onMouseDown={(e) => handleResizeStart(e, "top")}
/> />
<div <div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize" className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom')} onMouseDown={(e) => handleResizeStart(e, "bottom")}
/> />
<div <div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize" className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, 'left')} onMouseDown={(e) => handleResizeStart(e, "left")}
/> />
<div <div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize" className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, 'right')} onMouseDown={(e) => handleResizeStart(e, "right")}
/> />
{/* 角落调整 */} {/* 角落调整 */}
<div <div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize" className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, 'top-left')} onMouseDown={(e) => handleResizeStart(e, "top-left")}
/> />
<div <div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize" className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, 'top-right')} onMouseDown={(e) => handleResizeStart(e, "top-right")}
/> />
<div <div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize" className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom-left')} onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/> />
<div <div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize" className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom-right')} onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/> />
</> </>
)} )}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { import {
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
@@ -15,8 +15,8 @@ import {
X, X,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
Replace Replace,
} from 'lucide-react'; } from "lucide-react";
import { import {
SiJavascript, SiJavascript,
SiTypescript, SiTypescript,
@@ -43,13 +43,13 @@ import {
SiMarkdown, SiMarkdown,
SiGnubash, SiGnubash,
SiMysql, SiMysql,
SiDocker SiDocker,
} from 'react-icons/si'; } from "react-icons/si";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input'; import { Input } from "@/components/ui/input";
import CodeMirror from '@uiw/react-codemirror'; import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from '@uiw/codemirror-themes'; import { oneDark } from "@uiw/codemirror-themes";
import { languages, loadLanguage } from '@uiw/codemirror-extensions-langs'; import { languages, loadLanguage } from "@uiw/codemirror-extensions-langs";
interface FileItem { interface FileItem {
name: string; name: string;
@@ -75,123 +75,183 @@ interface FileViewerProps {
// 获取编程语言的官方图标 // 获取编程语言的官方图标
function getLanguageIcon(filename: string): React.ReactNode { function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() || ''; const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase(); const baseName = filename.toLowerCase();
// 特殊文件名处理 // 特殊文件名处理
if (['dockerfile'].includes(baseName)) { if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />; return <SiDocker className="w-6 h-6 text-blue-400" />;
} }
if (['makefile', 'rakefile', 'gemfile'].includes(baseName)) { if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
return <SiRuby className="w-6 h-6 text-red-500" />; return <SiRuby className="w-6 h-6 text-red-500" />;
} }
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
'js': <SiJavascript className="w-6 h-6 text-yellow-400" />, js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
'jsx': <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" />, ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
'tsx': <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" />, py: <SiPython className="w-6 h-6 text-blue-400" />,
'java': <SiOracle className="w-6 h-6 text-red-500" />, java: <SiOracle className="w-6 h-6 text-red-500" />,
'cpp': <SiCplusplus className="w-6 h-6 text-blue-600" />, cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
'c': <SiC className="w-6 h-6 text-blue-700" />, c: <SiC className="w-6 h-6 text-blue-700" />,
'cs': <SiDotnet className="w-6 h-6 text-purple-600" />, cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
'php': <SiPhp className="w-6 h-6 text-indigo-500" />, php: <SiPhp className="w-6 h-6 text-indigo-500" />,
'rb': <SiRuby className="w-6 h-6 text-red-500" />, rb: <SiRuby className="w-6 h-6 text-red-500" />,
'go': <SiGo className="w-6 h-6 text-cyan-500" />, go: <SiGo className="w-6 h-6 text-cyan-500" />,
'rs': <SiRust className="w-6 h-6 text-orange-600" />, rs: <SiRust className="w-6 h-6 text-orange-600" />,
'html': <SiHtml5 className="w-6 h-6 text-orange-500" />, html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
'css': <SiCss3 className="w-6 h-6 text-blue-500" />, css: <SiCss3 className="w-6 h-6 text-blue-500" />,
'scss': <SiSass className="w-6 h-6 text-pink-500" />, scss: <SiSass className="w-6 h-6 text-pink-500" />,
'sass': <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" />, less: <SiLess className="w-6 h-6 text-blue-600" />,
'json': <SiJson className="w-6 h-6 text-yellow-500" />, json: <SiJson className="w-6 h-6 text-yellow-500" />,
'xml': <SiXml className="w-6 h-6 text-orange-500" />, xml: <SiXml className="w-6 h-6 text-orange-500" />,
'yaml': <SiYaml className="w-6 h-6 text-red-400" />, yaml: <SiYaml className="w-6 h-6 text-red-400" />,
'yml': <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" />, toml: <SiToml className="w-6 h-6 text-orange-400" />,
'sql': <SiMysql className="w-6 h-6 text-blue-500" />, sql: <SiMysql className="w-6 h-6 text-blue-500" />,
'sh': <SiGnubash className="w-6 h-6 text-gray-700" />, sh: <SiGnubash className="w-6 h-6 text-gray-700" />,
'bash': <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" />, zsh: <SiShell className="w-6 h-6 text-gray-700" />,
'vue': <SiVuedotjs className="w-6 h-6 text-green-500" />, vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
'svelte': <SiSvelte className="w-6 h-6 text-orange-500" />, svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
'md': <SiMarkdown className="w-6 h-6 text-gray-600" />, md: <SiMarkdown className="w-6 h-6 text-gray-600" />,
'conf': <SiShell 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" /> ini: <Code className="w-6 h-6 text-gray-600" />,
}; };
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />; return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
} }
// 获取文件类型和图标 // 获取文件类型和图标
function getFileType(filename: string): { type: string; icon: React.ReactNode; color: string } { function getFileType(filename: string): {
const ext = filename.split('.').pop()?.toLowerCase() || ''; type: string;
icon: React.ReactNode;
color: string;
} {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']; const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm']; const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a']; const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
const textExts = ['txt', 'readme']; 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']; 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)) { if (imageExts.includes(ext)) {
return { type: 'image', icon: <ImageIcon className="w-6 h-6" />, color: 'text-green-500' }; return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (videoExts.includes(ext)) { } else if (videoExts.includes(ext)) {
return { type: 'video', icon: <Film className="w-6 h-6" />, color: 'text-purple-500' }; return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (audioExts.includes(ext)) { } else if (audioExts.includes(ext)) {
return { type: 'audio', icon: <Music className="w-6 h-6" />, color: 'text-pink-500' }; return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (textExts.includes(ext)) { } else if (textExts.includes(ext)) {
return { type: 'text', icon: <FileText className="w-6 h-6" />, color: 'text-blue-500' }; return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (codeExts.includes(ext)) { } else if (codeExts.includes(ext)) {
return { type: 'code', icon: getLanguageIcon(filename), color: 'text-yellow-500' }; return {
type: "code",
icon: getLanguageIcon(filename),
color: "text-yellow-500",
};
} else { } else {
return { type: 'unknown', icon: <FileIcon className="w-6 h-6" />, color: 'text-gray-500' }; return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-gray-500",
};
} }
} }
// 获取CodeMirror语言扩展 // 获取CodeMirror语言扩展
function getLanguageExtension(filename: string) { function getLanguageExtension(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase() || ''; const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase(); const baseName = filename.toLowerCase();
// 特殊文件名处理 // 特殊文件名处理
if (['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(baseName)) { if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName); return loadLanguage(baseName);
} }
// 根据扩展名映射 // 根据扩展名映射
const langMap: Record<string, string> = { const langMap: Record<string, string> = {
'js': 'javascript', js: "javascript",
'jsx': 'jsx', jsx: "jsx",
'ts': 'typescript', ts: "typescript",
'tsx': 'tsx', tsx: "tsx",
'py': 'python', py: "python",
'java': 'java', java: "java",
'cpp': 'cpp', cpp: "cpp",
'c': 'c', c: "c",
'cs': 'csharp', cs: "csharp",
'php': 'php', php: "php",
'rb': 'ruby', rb: "ruby",
'go': 'go', go: "go",
'rs': 'rust', rs: "rust",
'html': 'html', html: "html",
'css': 'css', css: "css",
'scss': 'sass', scss: "sass",
'less': 'less', less: "less",
'json': 'json', json: "json",
'xml': 'xml', xml: "xml",
'yaml': 'yaml', yaml: "yaml",
'yml': 'yaml', yml: "yaml",
'toml': 'toml', toml: "toml",
'sql': 'sql', sql: "sql",
'sh': 'shell', sh: "shell",
'bash': 'shell', bash: "shell",
'zsh': 'shell', zsh: "shell",
'vue': 'vue', vue: "vue",
'svelte': 'svelte', svelte: "svelte",
'md': 'markdown', md: "markdown",
'conf': 'shell', conf: "shell",
'ini': 'properties' ini: "properties",
}; };
const language = langMap[ext]; const language = langMap[ext];
@@ -200,32 +260,36 @@ function getLanguageExtension(filename: string) {
// 格式化文件大小 // 格式化文件大小
function formatFileSize(bytes?: number): string { function formatFileSize(bytes?: number): string {
if (!bytes) return 'Unknown size'; if (!bytes) return "Unknown size";
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
} }
export function FileViewer({ export function FileViewer({
file, file,
content = '', content = "",
savedContent = '', savedContent = "",
isLoading = false, isLoading = false,
isEditable = false, isEditable = false,
onContentChange, onContentChange,
onSave, onSave,
onDownload onDownload,
}: FileViewerProps) { }: FileViewerProps) {
const [editedContent, setEditedContent] = useState(content); const [editedContent, setEditedContent] = useState(content);
const [originalContent, setOriginalContent] = useState(savedContent || content); const [originalContent, setOriginalContent] = useState(
savedContent || content,
);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false); const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false); const [forceShowAsText, setForceShowAsText] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false); const [showSearchPanel, setShowSearchPanel] = useState(false);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState(''); const [replaceText, setReplaceText] = useState("");
const [showReplacePanel, setShowReplacePanel] = useState(false); const [showReplacePanel, setShowReplacePanel] = useState(false);
const [searchMatches, setSearchMatches] = useState<{ start: number; end: number }[]>([]); const [searchMatches, setSearchMatches] = useState<
{ start: number; end: number }[]
>([]);
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1); const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
const fileTypeInfo = getFileType(file.name); const fileTypeInfo = getFileType(file.name);
@@ -236,9 +300,10 @@ export function FileViewer({
// 检查是否应该显示为文本 // 检查是否应该显示为文本
const shouldShowAsText = const shouldShowAsText =
fileTypeInfo.type === 'text' || fileTypeInfo.type === "text" ||
fileTypeInfo.type === 'code' || fileTypeInfo.type === "code" ||
(fileTypeInfo.type === 'unknown' && (forceShowAsText || !file.size || file.size <= WARNING_SIZE)); (fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
// 检查文件是否过大 // 检查文件是否过大
const isLargeFile = file.size && file.size > WARNING_SIZE; const isLargeFile = file.size && file.size > WARNING_SIZE;
@@ -254,7 +319,7 @@ export function FileViewer({
setHasChanges(content !== (savedContent || content)); setHasChanges(content !== (savedContent || content));
// 如果是未知文件类型且文件较大,显示警告 // 如果是未知文件类型且文件较大,显示警告
if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) { if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true); setShowLargeFileWarning(true);
} else { } else {
setShowLargeFileWarning(false); setShowLargeFileWarning(false);
@@ -290,13 +355,13 @@ export function FileViewer({
} }
const matches: { start: number; end: number }[] = []; const matches: { start: number; end: number }[] = [];
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
let match; let match;
while ((match = regex.exec(editedContent)) !== null) { while ((match = regex.exec(editedContent)) !== null) {
matches.push({ matches.push({
start: match.index, start: match.index,
end: match.index + match[0].length end: match.index + match[0].length,
}); });
// 避免无限循环 // 避免无限循环
if (match.index === regex.lastIndex) regex.lastIndex++; if (match.index === regex.lastIndex) regex.lastIndex++;
@@ -314,20 +379,30 @@ export function FileViewer({
const goToPrevMatch = () => { const goToPrevMatch = () => {
if (searchMatches.length === 0) return; if (searchMatches.length === 0) return;
setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length); setCurrentMatchIndex(
(prev) => (prev - 1 + searchMatches.length) % searchMatches.length,
);
}; };
// 替换功能 // 替换功能
const handleFindReplace = (findText: string, replaceWithText: string, replaceAll: boolean = false) => { const handleFindReplace = (
findText: string,
replaceWithText: string,
replaceAll: boolean = false,
) => {
if (!findText) return; if (!findText) return;
let newContent = editedContent; let newContent = editedContent;
if (replaceAll) { if (replaceAll) {
newContent = newContent.replace(new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replaceWithText); newContent = newContent.replace(
new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
replaceWithText,
);
} else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) { } else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) {
// 替换当前匹配项 // 替换当前匹配项
const match = searchMatches[currentMatchIndex]; const match = searchMatches[currentMatchIndex];
newContent = editedContent.substring(0, match.start) + newContent =
editedContent.substring(0, match.start) +
replaceWithText + replaceWithText +
editedContent.substring(match.end); editedContent.substring(match.end);
} }
@@ -374,11 +449,11 @@ export function FileViewer({
"font-bold", "font-bold",
isCurrentMatch isCurrentMatch
? "text-red-600 bg-yellow-200" ? "text-red-600 bg-yellow-200"
: "text-blue-800 bg-blue-100" : "text-blue-800 bg-blue-100",
)} )}
> >
{text.substring(match.start, match.end)} {text.substring(match.start, match.end)}
</span> </span>,
); );
lastIndex = match.end; lastIndex = match.end;
@@ -428,7 +503,13 @@ export function FileViewer({
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size)}</span> <span>{formatFileSize(file.size)}</span>
{file.modified && <span>Modified: {file.modified}</span>} {file.modified && <span>Modified: {file.modified}</span>}
<span className={cn("px-2 py-1 rounded-full text-xs", fileTypeInfo.color, "bg-muted")}> <span
className={cn(
"px-2 py-1 rounded-full text-xs",
fileTypeInfo.color,
"bg-muted",
)}
>
{fileTypeInfo.type.toUpperCase()} {fileTypeInfo.type.toUpperCase()}
</span> </span>
</div> </div>
@@ -446,7 +527,6 @@ export function FileViewer({
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
Find
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -455,7 +535,6 @@ export function FileViewer({
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Replace className="w-4 h-4" /> <Replace className="w-4 h-4" />
Replace
</Button> </Button>
</> </>
)} )}
@@ -529,8 +608,9 @@ export function FileViewer({
<span className="text-xs text-muted-foreground min-w-[3rem]"> <span className="text-xs text-muted-foreground min-w-[3rem]">
{searchMatches.length > 0 {searchMatches.length > 0
? `${currentMatchIndex + 1}/${searchMatches.length}` ? `${currentMatchIndex + 1}/${searchMatches.length}`
: searchText ? '0/0' : '' : searchText
} ? "0/0"
: ""}
</span> </span>
</div> </div>
<Button <Button
@@ -538,7 +618,7 @@ export function FileViewer({
size="sm" size="sm"
onClick={() => { onClick={() => {
setShowSearchPanel(false); setShowSearchPanel(false);
setSearchText(''); setSearchText("");
setSearchMatches([]); setSearchMatches([]);
setCurrentMatchIndex(-1); setCurrentMatchIndex(-1);
}} }}
@@ -557,7 +637,9 @@ export function FileViewer({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleFindReplace(searchText, replaceText, false)} onClick={() =>
handleFindReplace(searchText, replaceText, false)
}
disabled={!searchText} disabled={!searchText}
> >
Replace Replace
@@ -584,19 +666,24 @@ export function FileViewer({
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" /> <AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
<div> <div>
<h3 className="font-medium text-foreground mb-2">Large File Warning</h3> <h3 className="font-medium text-foreground mb-2">
Large File Warning
</h3>
<p className="text-sm text-muted-foreground mb-3"> <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. This file is {formatFileSize(file.size)} in size, which may
cause performance issues when opened as text.
</p> </p>
{isTooLarge ? ( {isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4"> <div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
<p className="text-sm text-destructive font-medium"> <p className="text-sm text-destructive font-medium">
File is too large (&gt; 10MB) and cannot be opened as text for security reasons. File is too large (&gt; 10MB) and cannot be opened as
text for security reasons.
</p> </p>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground mb-4"> <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. Do you want to continue opening this file as text? This
may slow down your browser.
</p> </p>
)} )}
</div> </div>
@@ -636,14 +723,14 @@ export function FileViewer({
)} )}
{/* 图片预览 */} {/* 图片预览 */}
{fileTypeInfo.type === 'image' && !showLargeFileWarning && ( {fileTypeInfo.type === "image" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full"> <div className="p-6 flex items-center justify-center h-full">
<img <img
src={`data:image/*;base64,${content}`} src={`data:image/*;base64,${content}`}
alt={file.name} alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm" className="max-w-full max-h-full object-contain rounded-lg shadow-sm"
onError={(e) => { onError={(e) => {
(e.target as HTMLElement).style.display = 'none'; (e.target as HTMLElement).style.display = "none";
// Show error message instead // Show error message instead
}} }}
/> />
@@ -653,7 +740,7 @@ export function FileViewer({
{/* 文本和代码文件预览 */} {/* 文本和代码文件预览 */}
{shouldShowAsText && !showLargeFileWarning && ( {shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{fileTypeInfo.type === 'code' ? ( {fileTypeInfo.type === "code" ? (
// 代码文件使用CodeMirror // 代码文件使用CodeMirror
<div className="h-full"> <div className="h-full">
{searchText && searchMatches.length > 0 ? ( {searchText && searchMatches.length > 0 ? (
@@ -661,8 +748,11 @@ export function FileViewer({
<div className="h-full flex bg-muted"> <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"> <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) => ( {editedContent.split("\n").map((_, index) => (
<div key={index + 1} className="text-right leading-5 min-w-[2rem]"> <div
key={index + 1}
className="text-right leading-5 min-w-[2rem]"
>
{index + 1} {index + 1}
</div> </div>
))} ))}
@@ -677,7 +767,11 @@ export function FileViewer({
<CodeMirror <CodeMirror
value={editedContent} value={editedContent}
onChange={(value) => handleContentChange(value)} onChange={(value) => handleContentChange(value)}
extensions={getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []} extensions={
getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []
}
theme="dark" theme="dark"
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
@@ -688,7 +782,7 @@ export function FileViewer({
bracketMatching: true, bracketMatching: true,
closeBrackets: true, closeBrackets: true,
autocompletion: true, autocompletion: true,
highlightSelectionMatches: false highlightSelectionMatches: false,
}} }}
className="h-full overflow-auto" className="h-full overflow-auto"
readOnly={!isEditable} readOnly={!isEditable}
@@ -719,7 +813,7 @@ export function FileViewer({
) : ( ) : (
// 只有非可编辑文件(媒体文件)才显示为只读 // 只有非可编辑文件(媒体文件)才显示为只读
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground"> <div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{editedContent || content || 'File is empty'} {editedContent || content || "File is empty"}
</div> </div>
)} )}
</div> </div>
@@ -728,7 +822,7 @@ export function FileViewer({
)} )}
{/* 视频文件预览 */} {/* 视频文件预览 */}
{fileTypeInfo.type === 'video' && !showLargeFileWarning && ( {fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full"> <div className="p-6 flex items-center justify-center h-full">
<video <video
controls controls
@@ -741,10 +835,15 @@ export function FileViewer({
)} )}
{/* 音频文件预览 */} {/* 音频文件预览 */}
{fileTypeInfo.type === 'audio' && !showLargeFileWarning && ( {fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full"> <div className="p-6 flex items-center justify-center h-full">
<div className="text-center"> <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)}> <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" /> <Music className="w-12 h-12" />
</div> </div>
<audio <audio
@@ -759,13 +858,18 @@ export function FileViewer({
)} )}
{/* 未知文件类型 - 只在不能显示为文本且没有警告时显示 */} {/* 未知文件类型 - 只在不能显示为文本且没有警告时显示 */}
{fileTypeInfo.type === 'unknown' && !shouldShowAsText && !showLargeFileWarning && ( {fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" /> <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> <h3 className="text-lg font-medium mb-2">
Cannot preview this file type
</h3>
<p className="text-sm mb-4"> <p className="text-sm mb-4">
This file type is not supported for preview. You can download it to view in an external application. This file type is not supported for preview. You can download
it to view in an external application.
</p> </p>
{onDownload && ( {onDownload && (
<Button <Button
@@ -787,7 +891,9 @@ export function FileViewer({
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{file.path}</span> <span>{file.path}</span>
{hasChanges && ( {hasChanges && (
<span className="text-orange-600 font-medium"> Unsaved changes</span> <span className="text-orange-600 font-medium">
Unsaved changes
</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,9 +1,15 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from './FileViewer'; import { FileViewer } from "./FileViewer";
import { useWindowManager } from './WindowManager'; import { useWindowManager } from "./WindowManager";
import { downloadSSHFile, readSSHFile, writeSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios'; import {
import { toast } from 'sonner'; downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
interface FileItem { interface FileItem {
name: string; name: string;
@@ -25,7 +31,7 @@ interface SSHHost {
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
authType: 'password' | 'key'; authType: "password" | "key";
credentialId?: number; credentialId?: number;
userId?: number; userId?: number;
} }
@@ -46,27 +52,34 @@ export function FileWindow({
sshSessionId, sshSessionId,
sshHost, sshHost,
initialX = 100, initialX = 100,
initialY = 100 initialY = 100,
}: FileWindowProps) { }: FileWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, updateWindow, windows } = useWindowManager(); const {
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
windows,
} = useWindowManager();
const [content, setContent] = useState<string>(''); const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>(''); const [pendingContent, setPendingContent] = useState<string>("");
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find(w => w.id === windowId); const currentWindow = windows.find((w) => w.id === windowId);
// 确保SSH连接有效 // 确保SSH连接有效
const ensureSSHConnection = async () => { const ensureSSHConnection = async () => {
try { try {
// 首先检查SSH连接状态 // 首先检查SSH连接状态
const status = await getSSHStatus(sshSessionId); const status = await getSSHStatus(sshSessionId);
console.log('SSH connection status:', status); console.log("SSH connection status:", status);
if (!status.connected) { if (!status.connected) {
console.log('SSH not connected, attempting to reconnect...'); console.log("SSH not connected, attempting to reconnect...");
// 重新建立连接 // 重新建立连接
await connectSSH(sshSessionId, { await connectSSH(sshSessionId, {
@@ -79,13 +92,13 @@ export function FileWindow({
keyPassword: sshHost.keyPassword, keyPassword: sshHost.keyPassword,
authType: sshHost.authType, authType: sshHost.authType,
credentialId: sshHost.credentialId, credentialId: sshHost.credentialId,
userId: sshHost.userId userId: sshHost.userId,
}); });
console.log('SSH reconnection successful'); console.log("SSH reconnection successful");
} }
} catch (error) { } catch (error) {
console.log('SSH connection check/reconnect failed:', error); console.log("SSH connection check/reconnect failed:", error);
// 即使连接失败也尝试继续让具体的API调用报错 // 即使连接失败也尝试继续让具体的API调用报错
throw error; throw error;
} }
@@ -94,7 +107,7 @@ export function FileWindow({
// 加载文件内容 // 加载文件内容
useEffect(() => { useEffect(() => {
const loadFileContent = async () => { const loadFileContent = async () => {
if (file.type !== 'file') return; if (file.type !== "file") return;
try { try {
setIsLoading(true); setIsLoading(true);
@@ -103,7 +116,7 @@ export function FileWindow({
await ensureSSHConnection(); await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path); const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || ''; const fileContent = response.content || "";
setContent(fileContent); setContent(fileContent);
setPendingContent(fileContent); // 初始化待保存内容 setPendingContent(fileContent); // 初始化待保存内容
@@ -116,22 +129,54 @@ export function FileWindow({
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑 // 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
const mediaExtensions = [ const mediaExtensions = [
// 图片文件 // 图片文件
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff', 'ico', "jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
// 音频文件 // 音频文件
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma', "mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
// 视频文件 // 视频文件
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', "mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
// 压缩文件 // 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', "zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
// 二进制文件 // 二进制文件
'exe', 'dll', 'so', 'dylib', 'bin', 'iso' "exe",
"dll",
"so",
"dylib",
"bin",
"iso",
]; ];
const extension = file.name.split('.').pop()?.toLowerCase(); const extension = file.name.split(".").pop()?.toLowerCase();
// 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑 // 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑
setIsEditable(!mediaExtensions.includes(extension || '')); setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) { } catch (error: any) {
console.error('Failed to load file:', error); console.error("Failed to load file:", error);
// 检查是否是大文件错误 // 检查是否是大文件错误
const errorData = error?.response?.data; const errorData = error?.response?.data;
@@ -139,11 +184,18 @@ export function FileWindow({
toast.error(`File too large: ${errorData.error}`, { toast.error(`File too large: ${errorData.error}`, {
duration: 10000, // 10 seconds for important message duration: 10000, // 10 seconds for important message
}); });
} else if (error.message?.includes('connection') || error.message?.includes('established')) { } 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})`); toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else { } else {
toast.error(`Failed to load file: ${error.message || errorData?.error || 'Unknown error'}`); toast.error(
`Failed to load file: ${error.message || errorData?.error || "Unknown error"}`,
);
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -163,7 +215,7 @@ export function FileWindow({
await writeSSHFile(sshSessionId, file.path, newContent); await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent); setContent(newContent);
setPendingContent(''); // 清除待保存内容 setPendingContent(""); // 清除待保存内容
// 清除自动保存定时器 // 清除自动保存定时器
if (autoSaveTimerRef.current) { if (autoSaveTimerRef.current) {
@@ -171,15 +223,20 @@ export function FileWindow({
autoSaveTimerRef.current = null; autoSaveTimerRef.current = null;
} }
toast.success('File saved successfully'); toast.success("File saved successfully");
} catch (error: any) { } catch (error: any) {
console.error('Failed to save file:', error); console.error("Failed to save file:", error);
// 如果是连接错误,提供更明确的错误信息 // 如果是连接错误,提供更明确的错误信息
if (error.message?.includes('connection') || error.message?.includes('established')) { if (
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`); 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 { } else {
toast.error(`Failed to save file: ${error.message || 'Unknown error'}`); toast.error(`Failed to save file: ${error.message || "Unknown error"}`);
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -198,12 +255,12 @@ export function FileWindow({
// 设置新的1分钟自动保存定时器 // 设置新的1分钟自动保存定时器
autoSaveTimerRef.current = setTimeout(async () => { autoSaveTimerRef.current = setTimeout(async () => {
try { try {
console.log('Auto-saving file...'); console.log("Auto-saving file...");
await handleSave(newContent); await handleSave(newContent);
toast.success('File auto-saved'); toast.success("File auto-saved");
} catch (error) { } catch (error) {
console.error('Auto-save failed:', error); console.error("Auto-save failed:", error);
toast.error('Auto-save failed'); toast.error("Auto-save failed");
} }
}, 60000); // 1分钟 = 60000毫秒 }, 60000); // 1分钟 = 60000毫秒
}; };
@@ -233,10 +290,12 @@ export function FileWindow({
byteNumbers[i] = byteCharacters.charCodeAt(i); byteNumbers[i] = byteCharacters.charCodeAt(i);
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' }); const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = response.fileName || file.name; link.download = response.fileName || file.name;
document.body.appendChild(link); document.body.appendChild(link);
@@ -244,16 +303,23 @@ export function FileWindow({
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('File downloaded successfully'); toast.success("File downloaded successfully");
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to download file:', error); console.error("Failed to download file:", error);
// 如果是连接错误,提供更明确的错误信息 // 如果是连接错误,提供更明确的错误信息
if (error.message?.includes('connection') || error.message?.includes('established')) { if (
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`); 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 { } else {
toast.error(`Failed to download file: ${error.message || 'Unknown error'}`); toast.error(
`Failed to download file: ${error.message || "Unknown error"}`,
);
} }
} }
}; };

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from "react";
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from '../../Terminal/Terminal'; import { Terminal } from "../../Terminal/Terminal";
import { useWindowManager } from './WindowManager'; import { useWindowManager } from "./WindowManager";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -12,7 +12,7 @@ interface SSHHost {
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
authType: 'password' | 'key'; authType: "password" | "key";
credentialId?: number; credentialId?: number;
userId?: number; userId?: number;
} }
@@ -32,12 +32,13 @@ export function TerminalWindow({
initialPath, initialPath,
initialX = 200, initialX = 200,
initialY = 150, initialY = 150,
executeCommand executeCommand,
}: TerminalWindowProps) { }: TerminalWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager(); const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
// 获取当前窗口状态 // 获取当前窗口状态
const currentWindow = windows.find(w => w.id === windowId); const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) { if (!currentWindow) {
console.warn(`Window with id ${windowId} not found`); console.warn(`Window with id ${windowId} not found`);
return null; return null;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance { export interface WindowInstance {
id: string; id: string;
@@ -19,7 +19,7 @@ interface WindowManagerProps {
interface WindowManagerContextType { interface WindowManagerContextType {
windows: WindowInstance[]; windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, 'id' | 'zIndex'>) => string; openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void; closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void; minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void; maximizeWindow: (id: string) => void;
@@ -27,7 +27,8 @@ interface WindowManagerContextType {
updateWindow: (id: string, updates: Partial<WindowInstance>) => void; updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
} }
const WindowManagerContext = React.createContext<WindowManagerContextType | null>(null); const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) { export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]); const [windows, setWindows] = useState<WindowInstance[]>([]);
@@ -35,7 +36,8 @@ export function WindowManager({ children }: WindowManagerProps) {
const windowCounter = useRef(0); const windowCounter = useRef(0);
// 打开新窗口 // 打开新窗口
const openWindow = useCallback((windowData: Omit<WindowInstance, 'id' | 'zIndex'>) => { const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`; const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current; const zIndex = ++nextZIndex.current;
@@ -52,48 +54,55 @@ export function WindowManager({ children }: WindowManagerProps) {
y: adjustedY, y: adjustedY,
}; };
setWindows(prev => [...prev, newWindow]); setWindows((prev) => [...prev, newWindow]);
return id; return id;
}, [windows.length]); },
[windows.length],
);
// 关闭窗口 // 关闭窗口
const closeWindow = useCallback((id: string) => { const closeWindow = useCallback((id: string) => {
setWindows(prev => prev.filter(w => w.id !== id)); setWindows((prev) => prev.filter((w) => w.id !== id));
}, []); }, []);
// 最小化窗口 // 最小化窗口
const minimizeWindow = useCallback((id: string) => { const minimizeWindow = useCallback((id: string) => {
setWindows(prev => prev.map(w => setWindows((prev) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w prev.map((w) =>
)); w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []); }, []);
// 最大化/还原窗口 // 最大化/还原窗口
const maximizeWindow = useCallback((id: string) => { const maximizeWindow = useCallback((id: string) => {
setWindows(prev => prev.map(w => setWindows((prev) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w prev.map((w) =>
)); w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []); }, []);
// 聚焦窗口 (置于顶层) // 聚焦窗口 (置于顶层)
const focusWindow = useCallback((id: string) => { const focusWindow = useCallback((id: string) => {
setWindows(prev => { setWindows((prev) => {
const targetWindow = prev.find(w => w.id === id); const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev; if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current; const newZIndex = ++nextZIndex.current;
return prev.map(w => return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
w.id === id ? { ...w, zIndex: newZIndex } : w
);
}); });
}, []); }, []);
// 更新窗口属性 // 更新窗口属性
const updateWindow = useCallback((id: string, updates: Partial<WindowInstance>) => { const updateWindow = useCallback(
setWindows(prev => prev.map(w => (id: string, updates: Partial<WindowInstance>) => {
w.id === id ? { ...w, ...updates } : w setWindows((prev) =>
)); prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
}, []); );
},
[],
);
const contextValue: WindowManagerContextType = { const contextValue: WindowManagerContextType = {
windows, windows,
@@ -110,9 +119,9 @@ export function WindowManager({ children }: WindowManagerProps) {
{children} {children}
{/* 渲染所有窗口 */} {/* 渲染所有窗口 */}
<div className="window-container"> <div className="window-container">
{windows.map(window => ( {windows.map((window) => (
<div key={window.id}> <div key={window.id}>
{typeof window.component === 'function' {typeof window.component === "function"
? window.component(window.id) ? window.component(window.id)
: window.component} : window.component}
</div> </div>
@@ -126,7 +135,7 @@ export function WindowManager({ children }: WindowManagerProps) {
export function useWindowManager() { export function useWindowManager() {
const context = React.useContext(WindowManagerContext); const context = React.useContext(WindowManagerContext);
if (!context) { if (!context) {
throw new Error('useWindowManager must be used within a WindowManager'); throw new Error("useWindowManager must be used within a WindowManager");
} }
return context; return context;
} }

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from "react";
interface DragAndDropState { interface DragAndDropState {
isDragging: boolean; isDragging: boolean;
@@ -17,15 +17,16 @@ export function useDragAndDrop({
onFilesDropped, onFilesDropped,
onError, onError,
maxFileSize = 100, // 100MB default maxFileSize = 100, // 100MB default
allowedTypes = [] // empty means all types allowed allowedTypes = [], // empty means all types allowed
}: UseDragAndDropProps) { }: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({ const [state, setState] = useState<DragAndDropState>({
isDragging: false, isDragging: false,
dragCounter: 0, dragCounter: 0,
draggedFiles: [] draggedFiles: [],
}); });
const validateFiles = useCallback((files: FileList): string | null => { const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024; const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
@@ -38,55 +39,59 @@ export function useDragAndDrop({
// Check file type if restrictions exist // Check file type if restrictions exist
if (allowedTypes.length > 0) { if (allowedTypes.length > 0) {
const fileExt = file.name.split('.').pop()?.toLowerCase(); const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase(); const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some(type => { const isAllowed = allowedTypes.some((type) => {
// Check by extension // Check by extension
if (type.startsWith('.')) { if (type.startsWith(".")) {
return fileExt === type.slice(1); return fileExt === type.slice(1);
} }
// Check by MIME type // Check by MIME type
if (type.includes('/')) { if (type.includes("/")) {
return mimeType === type || mimeType.startsWith(type.replace('*', '')); return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
} }
// Check by category // Check by category
switch (type) { switch (type) {
case 'image': case "image":
return mimeType.startsWith('image/'); return mimeType.startsWith("image/");
case 'video': case "video":
return mimeType.startsWith('video/'); return mimeType.startsWith("video/");
case 'audio': case "audio":
return mimeType.startsWith('audio/'); return mimeType.startsWith("audio/");
case 'text': case "text":
return mimeType.startsWith('text/'); return mimeType.startsWith("text/");
default: default:
return false; return false;
} }
}); });
if (!isAllowed) { if (!isAllowed) {
return `File type "${file.type || 'unknown'}" is not allowed.`; return `File type "${file.type || "unknown"}" is not allowed.`;
} }
} }
} }
return null; return null;
}, [maxFileSize, allowedTypes]); },
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
dragCounter: prev.dragCounter + 1 dragCounter: prev.dragCounter + 1,
})); }));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDragging: true isDragging: true,
})); }));
} }
}, []); }, []);
@@ -95,12 +100,12 @@ export function useDragAndDrop({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setState(prev => { setState((prev) => {
const newCounter = prev.dragCounter - 1; const newCounter = prev.dragCounter - 1;
return { return {
...prev, ...prev,
dragCounter: newCounter, dragCounter: newCounter,
isDragging: newCounter > 0 isDragging: newCounter > 0,
}; };
}); });
}, []); }, []);
@@ -110,17 +115,18 @@ export function useDragAndDrop({
e.stopPropagation(); e.stopPropagation();
// Set dropEffect to indicate what operation is allowed // Set dropEffect to indicate what operation is allowed
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = "copy";
}, []); }, []);
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setState({ setState({
isDragging: false, isDragging: false,
dragCounter: 0, dragCounter: 0,
draggedFiles: [] draggedFiles: [],
}); });
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
@@ -136,13 +142,15 @@ export function useDragAndDrop({
} }
onFilesDropped(files); onFilesDropped(files);
}, [validateFiles, onFilesDropped, onError]); },
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => { const resetDragState = useCallback(() => {
setState({ setState({
isDragging: false, isDragging: false,
dragCounter: 0, dragCounter: 0,
draggedFiles: [] draggedFiles: [],
}); });
}, []); }, []);
@@ -152,8 +160,8 @@ export function useDragAndDrop({
onDragEnter: handleDragEnter, onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave, onDragLeave: handleDragLeave,
onDragOver: handleDragOver, onDragOver: handleDragOver,
onDrop: handleDrop onDrop: handleDrop,
}, },
resetDragState resetDragState,
}; };
} }

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from "react";
interface FileItem { interface FileItem {
name: string; name: string;
@@ -16,10 +16,10 @@ export function useFileSelection() {
const selectFile = useCallback((file: FileItem, multiSelect = false) => { const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) { if (multiSelect) {
setSelectedFiles(prev => { setSelectedFiles((prev) => {
const isSelected = prev.some(f => f.path === file.path); const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) { if (isSelected) {
return prev.filter(f => f.path !== file.path); return prev.filter((f) => f.path !== file.path);
} else { } else {
return [...prev, file]; return [...prev, file];
} }
@@ -29,9 +29,10 @@ export function useFileSelection() {
} }
}, []); }, []);
const selectRange = useCallback((files: FileItem[], startFile: FileItem, endFile: FileItem) => { const selectRange = useCallback(
const startIndex = files.findIndex(f => f.path === startFile.path); (files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const endIndex = files.findIndex(f => f.path === endFile.path); const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) { if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex); const start = Math.min(startIndex, endIndex);
@@ -39,7 +40,9 @@ export function useFileSelection() {
const rangeFiles = files.slice(start, end + 1); const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles); setSelectedFiles(rangeFiles);
} }
}, []); },
[],
);
const selectAll = useCallback((files: FileItem[]) => { const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]); setSelectedFiles([...files]);
@@ -50,26 +53,32 @@ export function useFileSelection() {
}, []); }, []);
const toggleSelection = useCallback((file: FileItem) => { const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles(prev => { setSelectedFiles((prev) => {
const isSelected = prev.some(f => f.path === file.path); const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) { if (isSelected) {
return prev.filter(f => f.path !== file.path); return prev.filter((f) => f.path !== file.path);
} else { } else {
return [...prev, file]; return [...prev, file];
} }
}); });
}, []); }, []);
const isSelected = useCallback((file: FileItem) => { const isSelected = useCallback(
return selectedFiles.some(f => f.path === file.path); (file: FileItem) => {
}, [selectedFiles]); return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => { const getSelectedCount = useCallback(() => {
return selectedFiles.length; return selectedFiles.length;
}, [selectedFiles]); }, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => { const setSelection = useCallback((files: FileItem[]) => {
console.log('Setting selection to:', files.map(f => f.name)); console.log(
"Setting selection to:",
files.map((f) => f.name),
);
setSelectedFiles(files); setSelectedFiles(files);
}, []); }, []);
@@ -82,6 +91,6 @@ export function useFileSelection() {
toggleSelection, toggleSelection,
isSelected, isSelected,
getSelectedCount, getSelectedCount,
setSelection setSelection,
}; };
} }

View File

@@ -208,7 +208,10 @@ export function HostManagerEditor({
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.authType === "password") { if (data.authType === "password") {
if (data.requirePassword && (!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"),
@@ -425,7 +428,11 @@ export function HostManagerEditor({
submitData.keyType = null; submitData.keyType = null;
if (data.authType === "credential") { if (data.authType === "credential") {
if (data.credentialId === "existing_credential" && editingHost && editingHost.id) { if (
data.credentialId === "existing_credential" &&
editingHost &&
editingHost.id
) {
delete submitData.credentialId; delete submitData.credentialId;
} else { } else {
submitData.credentialId = data.credentialId; submitData.credentialId = data.credentialId;
@@ -1521,7 +1528,11 @@ 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 ? editingHost.id ? t("hosts.updateHost") : t("hosts.cloneHost") : t("hosts.addHost")} {editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button> </Button>
</footer> </footer>
</form> </form>

View File

@@ -213,7 +213,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
delete clonedHost.id; delete clonedHost.id;
onEditHost(clonedHost); onEditHost(clonedHost);
} }
} };
const handleRemoveFromFolder = async (host: SSHHost) => { const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast( confirmWithToast(

View File

@@ -26,7 +26,14 @@ interface SSHTerminalProps {
} }
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal( export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false, onClose, initialPath, executeCommand }, {
hostConfig,
isVisible,
splitScreen = false,
onClose,
initialPath,
executeCommand,
},
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -458,8 +465,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Add macOS-specific keyboard event handling for special characters // Add macOS-specific keyboard event handling for special characters
const handleMacKeyboard = (e: KeyboardEvent) => { const handleMacKeyboard = (e: KeyboardEvent) => {
// Detect macOS // Detect macOS
const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0 || const isMacOS =
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
if (!isMacOS) return; if (!isMacOS) return;
@@ -468,18 +476,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Use both e.key and e.code to handle different keyboard layouts // Use both e.key and e.code to handle different keyboard layouts
const keyMappings: { [key: string]: string } = { const keyMappings: { [key: string]: string } = {
// Using e.key values // Using e.key values
'7': '|', // Option+7 = pipe symbol "7": "|", // Option+7 = pipe symbol
'2': '€', // Option+2 = euro symbol "2": "€", // Option+2 = euro symbol
'8': '[', // Option+8 = left bracket "8": "[", // Option+8 = left bracket
'9': ']', // Option+9 = right bracket "9": "]", // Option+9 = right bracket
'l': '@', // Option+L = at symbol l: "@", // Option+L = at symbol
'L': '@', // Option+L = at symbol (uppercase) L: "@", // Option+L = at symbol (uppercase)
// Using e.code values as fallback // Using e.code values as fallback
'Digit7': '|', // Option+7 = pipe symbol Digit7: "|", // Option+7 = pipe symbol
'Digit2': '€', // Option+2 = euro symbol Digit2: "€", // Option+2 = euro symbol
'Digit8': '[', // Option+8 = left bracket Digit8: "[", // Option+8 = left bracket
'Digit9': ']', // Option+9 = right bracket Digit9: "]", // Option+9 = right bracket
'KeyL': '@', // Option+L = at symbol KeyL: "@", // Option+L = at symbol
}; };
const char = keyMappings[e.key] || keyMappings[e.code]; const char = keyMappings[e.key] || keyMappings[e.code];
@@ -489,7 +497,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Send the character directly to the terminal // Send the character directly to the terminal
if (webSocketRef.current?.readyState === 1) { if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({ type: "input", data: char })); webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
} }
return false; return false;
} }

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from "react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { import {
Download, Download,
FileDown, FileDown,
FolderDown, FolderDown,
Loader2, Loader2,
CheckCircle, CheckCircle,
AlertCircle AlertCircle,
} from 'lucide-react'; } from "lucide-react";
interface DragIndicatorProps { interface DragIndicatorProps {
isVisible: boolean; isVisible: boolean;
@@ -28,7 +28,7 @@ export function DragIndicator({
fileName, fileName,
fileCount = 1, fileCount = 1,
error, error,
className className,
}: DragIndicatorProps) { }: DragIndicatorProps) {
if (!isVisible) return null; if (!isVisible) return null;
@@ -58,14 +58,14 @@ export function DragIndicator({
} }
if (isDragging) { if (isDragging) {
return `正在拖拽${fileName ? ` ${fileName}` : ''}到桌面...`; return `正在拖拽${fileName ? ` ${fileName}` : ""}到桌面...`;
} }
if (isDownloading) { if (isDownloading) {
return `正在准备拖拽${fileName ? ` ${fileName}` : ''}...`; return `正在准备拖拽${fileName ? ` ${fileName}` : ""}...`;
} }
return `准备拖拽${fileCount > 1 ? ` ${fileCount} 个文件` : fileName ? ` ${fileName}` : ''}`; return `准备拖拽${fileCount > 1 ? ` ${fileCount} 个文件` : fileName ? ` ${fileName}` : ""}`;
}; };
return ( return (
@@ -75,29 +75,31 @@ export function DragIndicator({
"bg-dark-bg border border-dark-border rounded-lg shadow-lg", "bg-dark-bg border border-dark-border rounded-lg shadow-lg",
"p-4 transition-all duration-300 ease-in-out", "p-4 transition-all duration-300 ease-in-out",
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full", isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full",
className className,
)} )}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* 图标 */} {/* 图标 */}
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
{getIcon()}
</div>
{/* 内容 */} {/* 内容 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* 标题 */} {/* 标题 */}
<div className="text-sm font-medium text-foreground mb-2"> <div className="text-sm font-medium text-foreground mb-2">
{fileCount > 1 ? '批量拖拽到桌面' : '拖拽到桌面'} {fileCount > 1 ? "批量拖拽到桌面" : "拖拽到桌面"}
</div> </div>
{/* 状态文字 */} {/* 状态文字 */}
<div className={cn( <div
className={cn(
"text-xs mb-3", "text-xs mb-3",
error ? "text-red-500" : error
isDragging ? "text-green-500" : ? "text-red-500"
"text-muted-foreground" : isDragging
)}> ? "text-green-500"
: "text-muted-foreground",
)}
>
{getStatusText()} {getStatusText()}
</div> </div>
@@ -107,7 +109,7 @@ export function DragIndicator({
<div <div
className={cn( className={cn(
"h-2 rounded-full transition-all duration-300", "h-2 rounded-full transition-all duration-300",
isDragging ? "bg-green-500" : "bg-blue-500" isDragging ? "bg-green-500" : "bg-blue-500",
)} )}
style={{ width: `${Math.max(5, progress)}%` }} style={{ width: `${Math.max(5, progress)}%` }}
/> />

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from "react";
import { toast } from 'sonner'; import { toast } from "sonner";
import { downloadSSHFile } from '@/ui/main-axios'; import { downloadSSHFile } from "@/ui/main-axios";
import type { FileItem, SSHHost } from '../../types/index.js'; import type { FileItem, SSHHost } from "../../types/index.js";
interface DragToDesktopState { interface DragToDesktopState {
isDragging: boolean; isDragging: boolean;
@@ -21,78 +21,86 @@ interface DragToDesktopOptions {
onError?: (error: string) => void; onError?: (error: string) => void;
} }
export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProps) { export function useDragToDesktop({
sshSessionId,
sshHost,
}: UseDragToDesktopProps) {
const [state, setState] = useState<DragToDesktopState>({ const [state, setState] = useState<DragToDesktopState>({
isDragging: false, isDragging: false,
isDownloading: false, isDownloading: false,
progress: 0, progress: 0,
error: null error: null,
}); });
// 检查是否在Electron环境中 // 检查是否在Electron环境中
const isElectron = () => { const isElectron = () => {
return typeof window !== 'undefined' && return (
typeof window !== "undefined" &&
window.electronAPI && window.electronAPI &&
window.electronAPI.isElectron; window.electronAPI.isElectron
);
}; };
// 拖拽单个文件到桌面 // 拖拽单个文件到桌面
const dragFileToDesktop = useCallback(async ( const dragFileToDesktop = useCallback(
file: FileItem, async (file: FileItem, options: DragToDesktopOptions = {}) => {
options: DragToDesktopOptions = {}
) => {
const { enableToast = true, onSuccess, onError } = options; const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) { if (!isElectron()) {
const error = '拖拽到桌面功能仅在桌面应用中可用'; const error = "拖拽到桌面功能仅在桌面应用中可用";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
if (file.type !== 'file') { if (file.type !== "file") {
const error = '只能拖拽文件到桌面'; const error = "只能拖拽文件到桌面";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
try { try {
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
// 下载文件内容 // 下载文件内容
const response = await downloadSSHFile(sshSessionId, file.path); const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) { if (!response?.content) {
throw new Error('无法获取文件内容'); throw new Error("无法获取文件内容");
} }
setState(prev => ({ ...prev, progress: 50 })); setState((prev) => ({ ...prev, progress: 50 }));
// 创建临时文件 // 创建临时文件
const tempResult = await window.electronAPI.createTempFile({ const tempResult = await window.electronAPI.createTempFile({
fileName: file.name, fileName: file.name,
content: response.content, content: response.content,
encoding: 'base64' encoding: "base64",
}); });
if (!tempResult.success) { if (!tempResult.success) {
throw new Error(tempResult.error || '创建临时文件失败'); throw new Error(tempResult.error || "创建临时文件失败");
} }
setState(prev => ({ ...prev, progress: 80, isDragging: true })); setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
// 开始拖拽 // 开始拖拽
const dragResult = await window.electronAPI.startDragToDesktop({ const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId, tempId: tempResult.tempId,
fileName: file.name fileName: file.name,
}); });
if (!dragResult.success) { if (!dragResult.success) {
throw new Error(dragResult.error || '开始拖拽失败'); throw new Error(dragResult.error || "开始拖拽失败");
} }
setState(prev => ({ ...prev, progress: 100 })); setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) { if (enableToast) {
toast.success(`正在拖拽 ${file.name} 到桌面`); toast.success(`正在拖拽 ${file.name} 到桌面`);
@@ -103,25 +111,25 @@ export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProp
// 延迟清理临时文件(给用户时间完成拖拽) // 延迟清理临时文件(给用户时间完成拖拽)
setTimeout(async () => { setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId); await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDragging: false, isDragging: false,
isDownloading: false, isDownloading: false,
progress: 0 progress: 0,
})); }));
}, 10000); // 10秒后清理 }, 10000); // 10秒后清理
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error('拖拽到桌面失败:', error); console.error("拖拽到桌面失败:", error);
const errorMessage = error.message || '拖拽失败'; const errorMessage = error.message || "拖拽失败";
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDownloading: false, isDownloading: false,
isDragging: false, isDragging: false,
progress: 0, progress: 0,
error: errorMessage error: errorMessage,
})); }));
if (enableToast) { if (enableToast) {
@@ -131,25 +139,25 @@ export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProp
onError?.(errorMessage); onError?.(errorMessage);
return false; return false;
} }
}, [sshSessionId, sshHost]); },
[sshSessionId, sshHost],
);
// 拖拽多个文件到桌面(批量操作) // 拖拽多个文件到桌面(批量操作)
const dragFilesToDesktop = useCallback(async ( const dragFilesToDesktop = useCallback(
files: FileItem[], async (files: FileItem[], options: DragToDesktopOptions = {}) => {
options: DragToDesktopOptions = {}
) => {
const { enableToast = true, onSuccess, onError } = options; const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) { if (!isElectron()) {
const error = '拖拽到桌面功能仅在桌面应用中可用'; const error = "拖拽到桌面功能仅在桌面应用中可用";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
const fileList = files.filter(f => f.type === 'file'); const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) { if (fileList.length === 0) {
const error = '没有可拖拽的文件'; const error = "没有可拖拽的文件";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
@@ -160,46 +168,51 @@ export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProp
} }
try { try {
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
// 批量下载文件 // 批量下载文件
const downloadPromises = fileList.map(file => const downloadPromises = fileList.map((file) =>
downloadSSHFile(sshSessionId, file.path) downloadSSHFile(sshSessionId, file.path),
); );
const responses = await Promise.all(downloadPromises); const responses = await Promise.all(downloadPromises);
setState(prev => ({ ...prev, progress: 40 })); setState((prev) => ({ ...prev, progress: 40 }));
// 创建临时文件夹结构 // 创建临时文件夹结构
const folderName = `Files_${Date.now()}`; const folderName = `Files_${Date.now()}`;
const filesData = fileList.map((file, index) => ({ const filesData = fileList.map((file, index) => ({
relativePath: file.name, relativePath: file.name,
content: responses[index]?.content || '', content: responses[index]?.content || "",
encoding: 'base64' encoding: "base64",
})); }));
const tempResult = await window.electronAPI.createTempFolder({ const tempResult = await window.electronAPI.createTempFolder({
folderName, folderName,
files: filesData files: filesData,
}); });
if (!tempResult.success) { if (!tempResult.success) {
throw new Error(tempResult.error || '创建临时文件夹失败'); throw new Error(tempResult.error || "创建临时文件夹失败");
} }
setState(prev => ({ ...prev, progress: 80, isDragging: true })); setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
// 开始拖拽文件夹 // 开始拖拽文件夹
const dragResult = await window.electronAPI.startDragToDesktop({ const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId, tempId: tempResult.tempId,
fileName: folderName fileName: folderName,
}); });
if (!dragResult.success) { if (!dragResult.success) {
throw new Error(dragResult.error || '开始拖拽失败'); throw new Error(dragResult.error || "开始拖拽失败");
} }
setState(prev => ({ ...prev, progress: 100 })); setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) { if (enableToast) {
toast.success(`正在拖拽 ${fileList.length} 个文件到桌面`); toast.success(`正在拖拽 ${fileList.length} 个文件到桌面`);
@@ -210,25 +223,25 @@ export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProp
// 延迟清理临时文件夹 // 延迟清理临时文件夹
setTimeout(async () => { setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId); await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDragging: false, isDragging: false,
isDownloading: false, isDownloading: false,
progress: 0 progress: 0,
})); }));
}, 15000); // 15秒后清理 }, 15000); // 15秒后清理
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error('批量拖拽到桌面失败:', error); console.error("批量拖拽到桌面失败:", error);
const errorMessage = error.message || '批量拖拽失败'; const errorMessage = error.message || "批量拖拽失败";
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDownloading: false, isDownloading: false,
isDragging: false, isDragging: false,
progress: 0, progress: 0,
error: errorMessage error: errorMessage,
})); }));
if (enableToast) { if (enableToast) {
@@ -238,44 +251,46 @@ export function useDragToDesktop({ sshSessionId, sshHost }: UseDragToDesktopProp
onError?.(errorMessage); onError?.(errorMessage);
return false; return false;
} }
}, [sshSessionId, sshHost, dragFileToDesktop]); },
[sshSessionId, sshHost, dragFileToDesktop],
);
// 拖拽文件夹到桌面 // 拖拽文件夹到桌面
const dragFolderToDesktop = useCallback(async ( const dragFolderToDesktop = useCallback(
folder: FileItem, async (folder: FileItem, options: DragToDesktopOptions = {}) => {
options: DragToDesktopOptions = {}
) => {
const { enableToast = true, onSuccess, onError } = options; const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) { if (!isElectron()) {
const error = '拖拽到桌面功能仅在桌面应用中可用'; const error = "拖拽到桌面功能仅在桌面应用中可用";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
if (folder.type !== 'directory') { if (folder.type !== "directory") {
const error = '只能拖拽文件夹类型'; const error = "只能拖拽文件夹类型";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
if (enableToast) { if (enableToast) {
toast.info('文件夹拖拽功能开发中...'); toast.info("文件夹拖拽功能开发中...");
} }
// TODO: 实现文件夹递归下载和拖拽 // TODO: 实现文件夹递归下载和拖拽
// 这需要额外的API来递归获取文件夹内容 // 这需要额外的API来递归获取文件夹内容
return false; return false;
}, [sshSessionId, sshHost]); },
[sshSessionId, sshHost],
);
return { return {
...state, ...state,
isElectron: isElectron(), isElectron: isElectron(),
dragFileToDesktop, dragFileToDesktop,
dragFilesToDesktop, dragFilesToDesktop,
dragFolderToDesktop dragFolderToDesktop,
}; };
} }

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from "react";
import { toast } from 'sonner'; import { toast } from "sonner";
import { downloadSSHFile } from '@/ui/main-axios'; import { downloadSSHFile } from "@/ui/main-axios";
import type { FileItem, SSHHost } from '../../types/index.js'; import type { FileItem, SSHHost } from "../../types/index.js";
interface DragToSystemState { interface DragToSystemState {
isDragging: boolean; isDragging: boolean;
@@ -21,64 +21,71 @@ interface DragToSystemOptions {
onError?: (error: string) => void; onError?: (error: string) => void;
} }
export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSystemProps) { export function useDragToSystemDesktop({
sshSessionId,
sshHost,
}: UseDragToSystemProps) {
const [state, setState] = useState<DragToSystemState>({ const [state, setState] = useState<DragToSystemState>({
isDragging: false, isDragging: false,
isDownloading: false, isDownloading: false,
progress: 0, progress: 0,
error: null error: null,
}); });
const dragDataRef = useRef<{ files: FileItem[], options: DragToSystemOptions } | null>(null); const dragDataRef = useRef<{
files: FileItem[];
options: DragToSystemOptions;
} | null>(null);
// 目录记忆功能 // 目录记忆功能
const getLastSaveDirectory = async () => { const getLastSaveDirectory = async () => {
try { try {
if ('indexedDB' in window) { if ("indexedDB" in window) {
const request = indexedDB.open('termix-dirs', 1); const request = indexedDB.open("termix-dirs", 1);
return new Promise((resolve) => { return new Promise((resolve) => {
request.onsuccess = () => { request.onsuccess = () => {
const db = request.result; const db = request.result;
const transaction = db.transaction(['directories'], 'readonly'); const transaction = db.transaction(["directories"], "readonly");
const store = transaction.objectStore('directories'); const store = transaction.objectStore("directories");
const getRequest = store.get('lastSaveDir'); const getRequest = store.get("lastSaveDir");
getRequest.onsuccess = () => resolve(getRequest.result?.handle || null); getRequest.onsuccess = () =>
resolve(getRequest.result?.handle || null);
}; };
request.onerror = () => resolve(null); request.onerror = () => resolve(null);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
const db = request.result; const db = request.result;
if (!db.objectStoreNames.contains('directories')) { if (!db.objectStoreNames.contains("directories")) {
db.createObjectStore('directories'); db.createObjectStore("directories");
} }
}; };
}); });
} }
} catch (error) { } catch (error) {
console.log('无法获取上次保存目录:', error); console.log("无法获取上次保存目录:", error);
} }
return null; return null;
}; };
const saveLastDirectory = async (fileHandle: any) => { const saveLastDirectory = async (fileHandle: any) => {
try { try {
if ('indexedDB' in window && fileHandle.getParent) { if ("indexedDB" in window && fileHandle.getParent) {
const dirHandle = await fileHandle.getParent(); const dirHandle = await fileHandle.getParent();
const request = indexedDB.open('termix-dirs', 1); const request = indexedDB.open("termix-dirs", 1);
request.onsuccess = () => { request.onsuccess = () => {
const db = request.result; const db = request.result;
const transaction = db.transaction(['directories'], 'readwrite'); const transaction = db.transaction(["directories"], "readwrite");
const store = transaction.objectStore('directories'); const store = transaction.objectStore("directories");
store.put({ handle: dirHandle }, 'lastSaveDir'); store.put({ handle: dirHandle }, "lastSaveDir");
}; };
} }
} catch (error) { } catch (error) {
console.log('无法保存目录记录:', error); console.log("无法保存目录记录:", error);
} }
}; };
// 检查File System Access API支持 // 检查File System Access API支持
const isFileSystemAPISupported = () => { const isFileSystemAPISupported = () => {
return 'showSaveFilePicker' in window; return "showSaveFilePicker" in window;
}; };
// 检查拖拽是否离开窗口边界 // 检查拖拽是否离开窗口边界
@@ -112,7 +119,7 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
// 创建ZIP文件用于多文件下载 // 创建ZIP文件用于多文件下载
const createZipBlob = async (files: FileItem[]): Promise<Blob> => { const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
// 这里需要一个轻量级的zip库先用简单方案 // 这里需要一个轻量级的zip库先用简单方案
const JSZip = (await import('jszip')).default; const JSZip = (await import("jszip")).default;
const zip = new JSZip(); const zip = new JSZip();
for (const file of files) { for (const file of files) {
@@ -120,7 +127,7 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
zip.file(file.name, blob); zip.file(file.name, blob);
} }
return await zip.generateAsync({ type: 'blob' }); return await zip.generateAsync({ type: "blob" });
}; };
// 使用File System Access API保存文件 // 使用File System Access API保存文件
@@ -131,15 +138,15 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
const fileHandle = await (window as any).showSaveFilePicker({ const fileHandle = await (window as any).showSaveFilePicker({
suggestedName, suggestedName,
startIn: lastDirHandle || 'desktop', // 优先使用上次目录,否则桌面 startIn: lastDirHandle || "desktop", // 优先使用上次目录,否则桌面
types: [ types: [
{ {
description: '文件', description: "文件",
accept: { accept: {
'*/*': ['.txt', '.jpg', '.png', '.pdf', '.zip', '.tar', '.gz'] "*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
} },
} },
] ],
}); });
// 保存当前目录句柄以便下次使用 // 保存当前目录句柄以便下次使用
@@ -151,7 +158,7 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
return true; return true;
} catch (error: any) { } catch (error: any) {
if (error.name === 'AbortError') { if (error.name === "AbortError") {
return false; // 用户取消 return false; // 用户取消
} }
throw error; throw error;
@@ -161,7 +168,7 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
// 降级方案:传统下载 // 降级方案:传统下载
const fallbackDownload = (blob: Blob, fileName: string) => { const fallbackDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = fileName; a.download = fileName;
document.body.appendChild(a); document.body.appendChild(a);
@@ -171,30 +178,33 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
}; };
// 处理拖拽到系统桌面 // 处理拖拽到系统桌面
const handleDragToSystem = useCallback(async ( const handleDragToSystem = useCallback(
files: FileItem[], async (files: FileItem[], options: DragToSystemOptions = {}) => {
options: DragToSystemOptions = {}
) => {
const { enableToast = true, onSuccess, onError } = options; const { enableToast = true, onSuccess, onError } = options;
if (files.length === 0) { if (files.length === 0) {
const error = '没有可拖拽的文件'; const error = "没有可拖拽的文件";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
// 过滤出文件类型 // 过滤出文件类型
const fileList = files.filter(f => f.type === 'file'); const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) { if (fileList.length === 0) {
const error = '只能拖拽文件到桌面'; const error = "只能拖拽文件到桌面";
if (enableToast) toast.error(error); if (enableToast) toast.error(error);
onError?.(error); onError?.(error);
return false; return false;
} }
try { try {
setState(prev => ({ ...prev, isDownloading: true, progress: 0, error: null })); setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
let blob: Blob; let blob: Blob;
let fileName: string; let fileName: string;
@@ -203,39 +213,43 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
// 单文件 // 单文件
blob = await createFileBlob(fileList[0]); blob = await createFileBlob(fileList[0]);
fileName = fileList[0].name; fileName = fileList[0].name;
setState(prev => ({ ...prev, progress: 70 })); setState((prev) => ({ ...prev, progress: 70 }));
} else { } else {
// 多文件打包成ZIP // 多文件打包成ZIP
blob = await createZipBlob(fileList); blob = await createZipBlob(fileList);
fileName = `files_${Date.now()}.zip`; fileName = `files_${Date.now()}.zip`;
setState(prev => ({ ...prev, progress: 70 })); setState((prev) => ({ ...prev, progress: 70 }));
} }
setState(prev => ({ ...prev, progress: 90 })); setState((prev) => ({ ...prev, progress: 90 }));
// 优先使用File System Access API // 优先使用File System Access API
if (isFileSystemAPISupported()) { if (isFileSystemAPISupported()) {
const saved = await saveFileWithSystemAPI(blob, fileName); const saved = await saveFileWithSystemAPI(blob, fileName);
if (!saved) { if (!saved) {
// 用户取消了 // 用户取消了
setState(prev => ({ ...prev, isDownloading: false, progress: 0 })); setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
}));
return false; return false;
} }
} else { } else {
// 降级到传统下载 // 降级到传统下载
fallbackDownload(blob, fileName); fallbackDownload(blob, fileName);
if (enableToast) { if (enableToast) {
toast.info('由于浏览器限制,文件将下载到默认下载目录'); toast.info("由于浏览器限制,文件将下载到默认下载目录");
} }
} }
setState(prev => ({ ...prev, progress: 100 })); setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) { if (enableToast) {
toast.success( toast.success(
fileList.length === 1 fileList.length === 1
? `${fileName} 已保存到指定位置` ? `${fileName} 已保存到指定位置`
: `${fileList.length} 个文件已打包保存` : `${fileList.length} 个文件已打包保存`,
); );
} }
@@ -243,19 +257,19 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
// 重置状态 // 重置状态
setTimeout(() => { setTimeout(() => {
setState(prev => ({ ...prev, isDownloading: false, progress: 0 })); setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
}, 1000); }, 1000);
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error('拖拽到桌面失败:', error); console.error("拖拽到桌面失败:", error);
const errorMessage = error.message || '保存失败'; const errorMessage = error.message || "保存失败";
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
isDownloading: false, isDownloading: false,
progress: 0, progress: 0,
error: errorMessage error: errorMessage,
})); }));
if (enableToast) { if (enableToast) {
@@ -265,16 +279,22 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
onError?.(errorMessage); onError?.(errorMessage);
return false; return false;
} }
}, [sshSessionId]); },
[sshSessionId],
);
// 开始拖拽(记录拖拽数据) // 开始拖拽(记录拖拽数据)
const startDragToSystem = useCallback((files: FileItem[], options: DragToSystemOptions = {}) => { const startDragToSystem = useCallback(
(files: FileItem[], options: DragToSystemOptions = {}) => {
dragDataRef.current = { files, options }; dragDataRef.current = { files, options };
setState(prev => ({ ...prev, isDragging: true, error: null })); setState((prev) => ({ ...prev, isDragging: true, error: null }));
}, []); },
[],
);
// 结束拖拽检测 // 结束拖拽检测
const handleDragEnd = useCallback((e: DragEvent) => { const handleDragEnd = useCallback(
(e: DragEvent) => {
if (!dragDataRef.current) return; if (!dragDataRef.current) return;
const { files, options } = dragDataRef.current; const { files, options } = dragDataRef.current;
@@ -289,13 +309,15 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
// 清理拖拽状态 // 清理拖拽状态
dragDataRef.current = null; dragDataRef.current = null;
setState(prev => ({ ...prev, isDragging: false })); setState((prev) => ({ ...prev, isDragging: false }));
}, [handleDragToSystem]); },
[handleDragToSystem],
);
// 取消拖拽 // 取消拖拽
const cancelDragToSystem = useCallback(() => { const cancelDragToSystem = useCallback(() => {
dragDataRef.current = null; dragDataRef.current = null;
setState(prev => ({ ...prev, isDragging: false, error: null })); setState((prev) => ({ ...prev, isDragging: false, error: null }));
}, []); }, []);
return { return {
@@ -304,6 +326,6 @@ export function useDragToSystemDesktop({ sshSessionId, sshHost }: UseDragToSyste
startDragToSystem, startDragToSystem,
handleDragEnd, handleDragEnd,
cancelDragToSystem, cancelDragToSystem,
handleDragToSystem // 直接调用版本 handleDragToSystem, // 直接调用版本
}; };
} }

View File

@@ -958,7 +958,7 @@ export async function getSSHStatus(
export async function listSSHFiles( export async function listSSHFiles(
sessionId: string, sessionId: string,
path: string, path: string,
): Promise<{files: any[], path: string}> { ): 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 },
@@ -1145,15 +1145,19 @@ export async function copySSHItem(
userId?: string, userId?: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await fileManagerApi.post("/ssh/copyItem", { const response = await fileManagerApi.post(
"/ssh/copyItem",
{
sessionId, sessionId,
sourcePath, sourcePath,
targetDir, targetDir,
hostId, hostId,
userId, userId,
}, { },
{
timeout: 60000, // 60秒超时因为文件复制可能需要更长时间 timeout: 60000, // 60秒超时因为文件复制可能需要更长时间
}); },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "copy SSH item"); handleApiError(error, "copy SSH item");
@@ -1213,7 +1217,7 @@ export async function moveSSHItem(
export async function getRecentFiles(hostId: number): Promise<any> { export async function getRecentFiles(hostId: number): Promise<any> {
try { try {
const response = await authApi.get("/ssh/file_manager/recent", { const response = await authApi.get("/ssh/file_manager/recent", {
params: { hostId } params: { hostId },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1225,13 +1229,13 @@ export async function getRecentFiles(hostId: number): Promise<any> {
export async function addRecentFile( export async function addRecentFile(
hostId: number, hostId: number,
path: string, path: string,
name?: string name?: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.post("/ssh/file_manager/recent", { const response = await authApi.post("/ssh/file_manager/recent", {
hostId, hostId,
path, path,
name name,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1242,11 +1246,11 @@ export async function addRecentFile(
export async function removeRecentFile( export async function removeRecentFile(
hostId: number, hostId: number,
path: string path: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.delete("/ssh/file_manager/recent", { const response = await authApi.delete("/ssh/file_manager/recent", {
data: { hostId, path } data: { hostId, path },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1259,7 +1263,7 @@ export async function removeRecentFile(
export async function getPinnedFiles(hostId: number): Promise<any> { export async function getPinnedFiles(hostId: number): Promise<any> {
try { try {
const response = await authApi.get("/ssh/file_manager/pinned", { const response = await authApi.get("/ssh/file_manager/pinned", {
params: { hostId } params: { hostId },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1271,13 +1275,13 @@ export async function getPinnedFiles(hostId: number): Promise<any> {
export async function addPinnedFile( export async function addPinnedFile(
hostId: number, hostId: number,
path: string, path: string,
name?: string name?: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.post("/ssh/file_manager/pinned", { const response = await authApi.post("/ssh/file_manager/pinned", {
hostId, hostId,
path, path,
name name,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1288,11 +1292,11 @@ export async function addPinnedFile(
export async function removePinnedFile( export async function removePinnedFile(
hostId: number, hostId: number,
path: string path: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.delete("/ssh/file_manager/pinned", { const response = await authApi.delete("/ssh/file_manager/pinned", {
data: { hostId, path } data: { hostId, path },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1305,7 +1309,7 @@ export async function removePinnedFile(
export async function getFolderShortcuts(hostId: number): Promise<any> { export async function getFolderShortcuts(hostId: number): Promise<any> {
try { try {
const response = await authApi.get("/ssh/file_manager/shortcuts", { const response = await authApi.get("/ssh/file_manager/shortcuts", {
params: { hostId } params: { hostId },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1317,13 +1321,13 @@ export async function getFolderShortcuts(hostId: number): Promise<any> {
export async function addFolderShortcut( export async function addFolderShortcut(
hostId: number, hostId: number,
path: string, path: string,
name?: string name?: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.post("/ssh/file_manager/shortcuts", { const response = await authApi.post("/ssh/file_manager/shortcuts", {
hostId, hostId,
path, path,
name name,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1334,11 +1338,11 @@ export async function addFolderShortcut(
export async function removeFolderShortcut( export async function removeFolderShortcut(
hostId: number, hostId: number,
path: string path: string,
): Promise<any> { ): Promise<any> {
try { try {
const response = await authApi.delete("/ssh/file_manager/shortcuts", { const response = await authApi.delete("/ssh/file_manager/shortcuts", {
data: { hostId, path } data: { hostId, path },
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1905,9 +1909,7 @@ export async function detectKeyType(
} }
} }
export async function detectPublicKeyType( export async function detectPublicKeyType(publicKey: string): Promise<any> {
publicKey: string,
): Promise<any> {
try { try {
const response = await authApi.post("/credentials/detect-public-key-type", { const response = await authApi.post("/credentials/detect-public-key-type", {
publicKey, publicKey,
@@ -1951,7 +1953,7 @@ export async function generatePublicKeyFromPrivate(
} }
export async function generateKeyPair( export async function generateKeyPair(
keyType: 'ssh-ed25519' | 'ssh-rsa' | 'ecdsa-sha2-nistp256', keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
keySize?: number, keySize?: number,
passphrase?: string, passphrase?: string,
): Promise<any> { ): Promise<any> {
@@ -1974,7 +1976,7 @@ export async function deployCredentialToHost(
try { try {
const response = await authApi.post( const response = await authApi.post(
`/credentials/${credentialId}/deploy-to-host`, `/credentials/${credentialId}/deploy-to-host`,
{ targetHostId } { targetHostId },
); );
return response.data; return response.data;
} catch (error) { } catch (error) {

View File

@@ -28,9 +28,12 @@
field.onChange(file); field.onChange(file);
try { try {
const fileContent = await file.text(); const fileContent = await file.text();
debouncedKeyDetection(fileContent, form.watch("keyPassword")); debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) { } catch (error) {
console.error('Failed to read uploaded file:', error); console.error("Failed to read uploaded file:", error);
} }
} }
}} }}
@@ -69,7 +72,10 @@
value={typeof field.value === "string" ? field.value : ""} value={typeof field.value === "string" ? field.value : ""}
onChange={(e) => { onChange={(e) => {
field.onChange(e.target.value); field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword")); debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}} }}
/> />
</FormControl> </FormControl>
@@ -81,16 +87,22 @@
{/* Key type detection display */} {/* Key type detection display */}
{detectedKeyType && ( {detectedKeyType && (
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span> <span className="text-muted-foreground">
<span className={`font-medium ${ {t("credentials.detectedKeyType")}:{" "}
detectedKeyType === 'invalid' || detectedKeyType === 'error' </span>
? 'text-destructive' <span
: 'text-green-600' className={`font-medium ${
}`}> detectedKeyType === "invalid" || detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)} {getFriendlyKeyTypeName(detectedKeyType)}
</span> </span>
{keyDetectionLoading && ( {keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span> <span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)} )}
</div> </div>
)} )}
@@ -98,7 +110,9 @@
{/* Show existing private key for editing */} {/* Show existing private key for editing */}
{editingCredential && fullCredentialDetails?.key && ( {editingCredential && fullCredentialDetails?.key && (
<FormItem> <FormItem>
<FormLabel>{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})</FormLabel> <FormLabel>
{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl> <FormControl>
<textarea <textarea
readOnly readOnly
@@ -151,7 +165,10 @@
field.onChange(fileContent); field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent); debouncedPublicKeyDetection(fileContent);
} catch (error) { } catch (error) {
console.error('Failed to read uploaded public key file:', error); console.error(
"Failed to read uploaded public key file:",
error,
);
} }
} }
}} }}
@@ -163,7 +180,9 @@
className="w-full justify-start text-left" className="w-full justify-start text-left"
> >
<span className="truncate"> <span className="truncate">
{field.value ? t("credentials.publicKeyUploaded") : t("credentials.uploadPublicKey")} {field.value
? t("credentials.publicKeyUploaded")
: t("credentials.uploadPublicKey")}
</span> </span>
</Button> </Button>
</div> </div>
@@ -200,16 +219,23 @@
{/* Public key type detection */} {/* Public key type detection */}
{detectedPublicKeyType && form.watch("publicKey") && ( {detectedPublicKeyType && form.watch("publicKey") && (
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span> <span className="text-muted-foreground">
<span className={`font-medium ${ {t("credentials.detectedKeyType")}:{" "}
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error' </span>
? 'text-destructive' <span
: 'text-green-600' className={`font-medium ${
}`}> detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedPublicKeyType)} {getFriendlyKeyTypeName(detectedPublicKeyType)}
</span> </span>
{publicKeyDetectionLoading && ( {publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span> <span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)} )}
</div> </div>
)} )}
@@ -221,7 +247,9 @@
{/* Show existing public key for editing */} {/* Show existing public key for editing */}
{editingCredential && fullCredentialDetails?.publicKey && ( {editingCredential && fullCredentialDetails?.publicKey && (
<FormItem> <FormItem>
<FormLabel>{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})</FormLabel> <FormLabel>
{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl> <FormControl>
<textarea <textarea
readOnly readOnly
@@ -263,4 +291,4 @@
</div> </div>
)} )}
</div> </div>
</TabsContent> </TabsContent>;