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:
- development
paths-ignore:
- '**.md'
- '.gitignore'
- 'docker/**'
- "**.md"
- ".gitignore"
- "docker/**"
workflow_dispatch:
inputs:
build_type:
@@ -34,8 +34,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
@@ -77,8 +77,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci

View File

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

View File

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

View File

@@ -326,31 +326,31 @@ const tempFiles = new Map(); // 存储临时文件路径映射
// 创建临时文件
ipcMain.handle("create-temp-file", async (event, fileData) => {
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)) {
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}`);
// 写入文件内容
if (encoding === 'base64') {
const buffer = Buffer.from(content, 'base64');
if (encoding === "base64") {
const buffer = Buffer.from(content, "base64");
fs.writeFileSync(tempFilePath, buffer);
} else {
fs.writeFileSync(tempFilePath, content, 'utf8');
fs.writeFileSync(tempFilePath, content, "utf8");
}
// 记录临时文件
tempFiles.set(tempId, {
path: tempFilePath,
fileName: fileName,
createdAt: Date.now()
createdAt: Date.now(),
});
console.log(`Created temp file: ${tempFilePath}`);
@@ -375,7 +375,7 @@ ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => {
mainWindow.webContents.startDrag({
file: tempFile.path,
icon: iconExists ? iconPath : undefined
icon: iconExists ? iconPath : undefined,
});
console.log(`Started drag for: ${tempFile.path}`);
@@ -431,12 +431,12 @@ ipcMain.handle("create-temp-folder", async (event, 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)) {
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}`);
// 递归创建文件夹结构
@@ -451,11 +451,11 @@ ipcMain.handle("create-temp-folder", async (event, folderData) => {
}
// 写入文件
if (file.encoding === 'base64') {
const buffer = Buffer.from(file.content, 'base64');
if (file.encoding === "base64") {
const buffer = Buffer.from(file.content, "base64");
fs.writeFileSync(fullPath, buffer);
} 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,
fileName: folderName,
createdAt: Date.now(),
isFolder: true
isFolder: true,
});
console.log(`Created temp folder: ${tempFolderPath}`);

View File

@@ -26,13 +26,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
// ================== 拖拽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),

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,11 +50,13 @@ export function CredentialEditor({
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
@@ -230,8 +232,11 @@ export function CredentialEditor({
}, []);
// Detect key type function
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => {
if (!keyValue || keyValue.trim() === '') {
const handleKeyTypeDetection = async (
keyValue: string,
keyPassword?: string,
) => {
if (!keyValue || keyValue.trim() === "") {
setDetectedKeyType(null);
return;
}
@@ -242,12 +247,12 @@ export function CredentialEditor({
if (result.success) {
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType('invalid');
console.warn('Key detection failed:', result.error);
setDetectedKeyType("invalid");
console.warn("Key detection failed:", result.error);
}
} catch (error) {
setDetectedKeyType('error');
console.error('Key type detection error:', error);
setDetectedKeyType("error");
console.error("Key type detection error:", error);
} finally {
setKeyDetectionLoading(false);
}
@@ -265,7 +270,7 @@ export function CredentialEditor({
// Detect public key type function
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === '') {
if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null);
return;
}
@@ -276,12 +281,12 @@ export function CredentialEditor({
if (result.success) {
setDetectedPublicKeyType(result.keyType);
} else {
setDetectedPublicKeyType('invalid');
console.warn('Public key detection failed:', result.error);
setDetectedPublicKeyType("invalid");
console.warn("Public key detection failed:", result.error);
}
} catch (error) {
setDetectedPublicKeyType('error');
console.error('Public key type detection error:', error);
setDetectedPublicKeyType("error");
console.error("Public key type detection error:", error);
} finally {
setPublicKeyDetectionLoading(false);
}
@@ -297,20 +302,19 @@ export function CredentialEditor({
}, 1000);
};
const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA (SSH)',
'ssh-ed25519': 'Ed25519 (SSH)',
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)',
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)',
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)',
'ssh-dss': 'DSA (SSH)',
'rsa-sha2-256': 'RSA-SHA2-256',
'rsa-sha2-512': 'RSA-SHA2-512',
'invalid': 'Invalid Key',
'error': 'Detection Error',
'unknown': 'Unknown'
"ssh-rsa": "RSA (SSH)",
"ssh-ed25519": "Ed25519 (SSH)",
"ecdsa-sha2-nistp256": "ECDSA P-256 (SSH)",
"ecdsa-sha2-nistp384": "ECDSA P-384 (SSH)",
"ecdsa-sha2-nistp521": "ECDSA P-521 (SSH)",
"ssh-dss": "DSA (SSH)",
"rsa-sha2-256": "RSA-SHA2-256",
"rsa-sha2-512": "RSA-SHA2-512",
invalid: "Invalid Key",
error: "Detection Error",
unknown: "Unknown",
};
return keyTypeMap[keyType] || keyType;
};
@@ -418,7 +422,6 @@ export function CredentialEditor({
};
}, [folderDropdownOpen]);
return (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
@@ -680,12 +683,15 @@ export function CredentialEditor({
{/* Key Generation Passphrase Input */}
<div className="mb-3">
<FormLabel className="text-sm mb-2 block">
{t("credentials.keyPassword")} ({t("credentials.optional")})
{t("credentials.keyPassword")} (
{t("credentials.optional")})
</FormLabel>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyGenerationPassphrase}
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
onChange={(e) =>
setKeyGenerationPassphrase(e.target.value)
}
className="max-w-xs"
/>
<div className="text-xs text-muted-foreground mt-1">
@@ -700,24 +706,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate Ed25519 key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -729,24 +758,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate ECDSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -758,24 +810,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
const result = await generateKeyPair(
"ssh-rsa",
2048,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate RSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -786,207 +861,267 @@ export function CredentialEditor({
{t("credentials.generateKeyPairNote")}
</div>
</div>
<div className="grid grid-cols-2 gap-4 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
} catch (error) {
console.error('Failed to read uploaded file:', error);
}
<div className="grid grid-cols-2 gap-4 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error(
"Failed to read uploaded file:",
error,
);
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedKeyType === 'invalid' || detectedKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
<div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error('Failed to read uploaded public key file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) {
toast.error(t("credentials.privateKeyRequiredForGeneration"));
return;
}
try {
const keyPassword = form.watch("keyPassword");
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.publicKeyGeneratedSuccessfully"));
} else {
toast.error(result.error || t("credentials.failedToGeneratePublicKey"));
}
} catch (error) {
console.error('Failed to generate public key:', error);
toast.error(t("credentials.failedToGeneratePublicKey"));
}
}}
className="w-full justify-start text-left"
>
{t("credentials.generatePublicKey")}
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePublicKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} (
{t("credentials.optional")})
</FormLabel>
<div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(
fileContent,
);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return;
}
try {
const keyPassword =
form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else {
toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TabsContent>
</Tabs>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -383,12 +383,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try {
// Extract just the symlink path (before the " -> " if present)
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
: item.path;
let currentSessionId = sshSessionId;
// Check SSH connection status and reconnect if needed
if (currentSessionId) {
try {
@@ -421,9 +421,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
throw new Error(t("fileManager.failedToConnectSSH"));
}
}
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath);
const symlinkInfo = await identifySSHSymlink(
currentSessionId,
symlinkPath,
);
if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it
handlePathChange(symlinkInfo.target);
@@ -438,9 +441,9 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
);
}
};
@@ -569,13 +572,13 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
(item.type === "directory"
? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
}
>
{item.type === "directory" ? (
@@ -590,7 +593,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</span>
</div>
<div className="flex items-center gap-1">
{(item.type === "file") && (
{item.type === "file" && (
<Button
size="icon"
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);
// 检查文件类型,决定读取方式
const isTextFile = uploadFile.type.startsWith('text/') ||
uploadFile.type === 'application/json' ||
uploadFile.type === 'application/javascript' ||
uploadFile.type === 'application/xml' ||
uploadFile.name.match(/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i);
const isTextFile =
uploadFile.type.startsWith("text/") ||
uploadFile.type === "application/json" ||
uploadFile.type === "application/javascript" ||
uploadFile.type === "application/xml" ||
uploadFile.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error('Failed to read text file content'));
reject(new Error("Failed to read text file content"));
}
};
reader.readAsText(uploadFile);
@@ -105,14 +108,14 @@ export function FileManagerOperations({
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result);
let binary = '';
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error('Failed to read binary file'));
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(uploadFile);
@@ -201,7 +204,7 @@ export function FileManagerOperations({
setIsLoading(true);
const { toast } = await import("sonner");
const fileName = downloadPath.split('/').pop() || 'download';
const fileName = downloadPath.split("/").pop() || "download";
const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }),
);
@@ -209,10 +212,7 @@ export function FileManagerOperations({
try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile(
sshSessionId,
downloadPath.trim(),
);
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
toast.dismiss(loadingToast);
@@ -224,11 +224,13 @@ export function FileManagerOperations({
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
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
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || fileName;
document.body.appendChild(link);
@@ -237,7 +239,9 @@ export function FileManagerOperations({
URL.revokeObjectURL(url);
onSuccess(
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }),
t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || fileName,
}),
);
} else {
onError(t("fileManager.noFileContent"));

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { DiffViewer } from './DiffViewer';
import { useWindowManager } from './WindowManager';
import type { FileItem, SSHHost } from '../../../../types/index.js';
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from "./WindowManager";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffWindowProps {
windowId: string;
@@ -21,11 +21,12 @@ export function DiffWindow({
sshSessionId,
sshHost,
initialX = 150,
initialY = 100
initialY = 100,
}: 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 = () => {
@@ -72,4 +73,4 @@ export function DiffWindow({
/>
</DraggableWindow>
);
}
}

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
import React, { useState, useEffect, useRef } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { FileViewer } from './FileViewer';
import { useWindowManager } from './WindowManager';
import { downloadSSHFile, readSSHFile, writeSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios';
import { toast } from 'sonner';
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
interface FileItem {
name: string;
@@ -25,7 +31,7 @@ interface SSHHost {
password?: string;
key?: string;
keyPassword?: string;
authType: 'password' | 'key';
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
@@ -46,27 +52,34 @@ export function FileWindow({
sshSessionId,
sshHost,
initialX = 100,
initialY = 100
initialY = 100,
}: 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 [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>('');
const [pendingContent, setPendingContent] = useState<string>("");
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find(w => w.id === windowId);
const currentWindow = windows.find((w) => w.id === windowId);
// 确保SSH连接有效
const ensureSSHConnection = async () => {
try {
// 首先检查SSH连接状态
const status = await getSSHStatus(sshSessionId);
console.log('SSH connection status:', status);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log('SSH not connected, attempting to reconnect...');
console.log("SSH not connected, attempting to reconnect...");
// 重新建立连接
await connectSSH(sshSessionId, {
@@ -79,13 +92,13 @@ export function FileWindow({
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId
userId: sshHost.userId,
});
console.log('SSH reconnection successful');
console.log("SSH reconnection successful");
}
} catch (error) {
console.log('SSH connection check/reconnect failed:', error);
console.log("SSH connection check/reconnect failed:", error);
// 即使连接失败也尝试继续让具体的API调用报错
throw error;
}
@@ -94,7 +107,7 @@ export function FileWindow({
// 加载文件内容
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== 'file') return;
if (file.type !== "file") return;
try {
setIsLoading(true);
@@ -103,7 +116,7 @@ export function FileWindow({
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || '';
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent); // 初始化待保存内容
@@ -116,22 +129,54 @@ export function FileWindow({
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
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) {
console.error('Failed to load file:', error);
console.error("Failed to load file:", error);
// 检查是否是大文件错误
const errorData = error?.response?.data;
@@ -139,11 +184,18 @@ export function FileWindow({
toast.error(`File too large: ${errorData.error}`, {
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 {
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 {
setIsLoading(false);
@@ -163,7 +215,7 @@ export function FileWindow({
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent(''); // 清除待保存内容
setPendingContent(""); // 清除待保存内容
// 清除自动保存定时器
if (autoSaveTimerRef.current) {
@@ -171,15 +223,20 @@ export function FileWindow({
autoSaveTimerRef.current = null;
}
toast.success('File saved successfully');
toast.success("File saved successfully");
} 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')) {
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`Failed to save file: ${error.message || 'Unknown error'}`);
toast.error(`Failed to save file: ${error.message || "Unknown error"}`);
}
} finally {
setIsLoading(false);
@@ -198,12 +255,12 @@ export function FileWindow({
// 设置新的1分钟自动保存定时器
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log('Auto-saving file...');
console.log("Auto-saving file...");
await handleSave(newContent);
toast.success('File auto-saved');
toast.success("File auto-saved");
} catch (error) {
console.error('Auto-save failed:', error);
toast.error('Auto-save failed');
console.error("Auto-save failed:", error);
toast.error("Auto-save failed");
}
}, 60000); // 1分钟 = 60000毫秒
};
@@ -233,10 +290,12 @@ export function FileWindow({
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
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 link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
@@ -244,16 +303,23 @@ export function FileWindow({
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('File downloaded successfully');
toast.success("File downloaded successfully");
}
} 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')) {
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`Failed to download file: ${error.message || 'Unknown error'}`);
toast.error(
`Failed to download file: ${error.message || "Unknown error"}`,
);
}
}
};
@@ -307,4 +373,4 @@ export function FileWindow({
/>
</DraggableWindow>
);
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { Terminal } from '../../Terminal/Terminal';
import { useWindowManager } from './WindowManager';
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from "../../Terminal/Terminal";
import { useWindowManager } from "./WindowManager";
interface SSHHost {
id: number;
@@ -12,7 +12,7 @@ interface SSHHost {
password?: string;
key?: string;
keyPassword?: string;
authType: 'password' | 'key';
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
@@ -32,12 +32,13 @@ export function TerminalWindow({
initialPath,
initialX = 200,
initialY = 150,
executeCommand
executeCommand,
}: 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) {
console.warn(`Window with id ${windowId} not found`);
return null;
@@ -62,8 +63,8 @@ export function TerminalWindow({
const terminalTitle = executeCommand
? `运行 - ${hostConfig.name}:${executeCommand}`
: initialPath
? `终端 - ${hostConfig.name}:${initialPath}`
: `终端 - ${hostConfig.name}`;
? `终端 - ${hostConfig.name}:${initialPath}`
: `终端 - ${hostConfig.name}`;
return (
<DraggableWindow
@@ -90,4 +91,4 @@ export function TerminalWindow({
/>
</DraggableWindow>
);
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
@@ -17,76 +17,81 @@ export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 100, // 100MB default
allowedTypes = [] // empty means all types allowed
allowedTypes = [], // empty means all types allowed
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: []
draggedFiles: [],
});
const validateFiles = useCallback((files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
// Check file size
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
// Check file type if restrictions exist
if (allowedTypes.length > 0) {
const fileExt = file.name.split('.').pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
// Check file type if restrictions exist
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some(type => {
// Check by extension
if (type.startsWith('.')) {
return fileExt === type.slice(1);
const isAllowed = allowedTypes.some((type) => {
// Check by extension
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
// Check by MIME type
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
// Check by category
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
// Check by MIME type
if (type.includes('/')) {
return mimeType === type || mimeType.startsWith(type.replace('*', ''));
}
// Check by category
switch (type) {
case 'image':
return mimeType.startsWith('image/');
case 'video':
return mimeType.startsWith('video/');
case 'audio':
return mimeType.startsWith('audio/');
case 'text':
return mimeType.startsWith('text/');
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || 'unknown'}" is not allowed.`;
}
}
}
return null;
}, [maxFileSize, allowedTypes]);
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState(prev => ({
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState(prev => ({
setState((prev) => ({
...prev,
isDragging: true
isDragging: true,
}));
}
}, []);
@@ -95,12 +100,12 @@ export function useDragAndDrop({
e.preventDefault();
e.stopPropagation();
setState(prev => {
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0
isDragging: newCounter > 0,
};
});
}, []);
@@ -110,39 +115,42 @@ export function useDragAndDrop({
e.stopPropagation();
// Set dropEffect to indicate what operation is allowed
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
});
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
}, [validateFiles, onFilesDropped, onError]);
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
draggedFiles: [],
});
}, []);
@@ -152,8 +160,8 @@ export function useDragAndDrop({
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
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 {
name: string;
@@ -16,10 +16,10 @@ export function useFileSelection() {
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
@@ -29,17 +29,20 @@ export function useFileSelection() {
}
}, []);
const selectRange = useCallback((files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex(f => f.path === startFile.path);
const endIndex = files.findIndex(f => f.path === endFile.path);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
}, []);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
@@ -50,26 +53,32 @@ export function useFileSelection() {
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback((file: FileItem) => {
return selectedFiles.some(f => f.path === file.path);
}, [selectedFiles]);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
console.log('Setting selection to:', files.map(f => f.name));
console.log(
"Setting selection to:",
files.map((f) => f.name),
);
setSelectedFiles(files);
}, []);
@@ -82,6 +91,6 @@ export function useFileSelection() {
toggleSelection,
isSelected,
getSelectedCount,
setSelection
setSelection,
};
}
}

View File

@@ -208,7 +208,10 @@ export function HostManagerEditor({
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (data.requirePassword && (!data.password || data.password.trim() === "")) {
if (
data.requirePassword &&
(!data.password || data.password.trim() === "")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
@@ -425,7 +428,11 @@ export function HostManagerEditor({
submitData.keyType = null;
if (data.authType === "credential") {
if (data.credentialId === "existing_credential" && editingHost && editingHost.id) {
if (
data.credentialId === "existing_credential" &&
editingHost &&
editingHost.id
) {
delete submitData.credentialId;
} else {
submitData.credentialId = data.credentialId;
@@ -1521,7 +1528,11 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingHost ? editingHost.id ? t("hosts.updateHost") : t("hosts.cloneHost") : t("hosts.addHost")}
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
</footer>
</form>

View File

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

View File

@@ -26,7 +26,14 @@ interface SSHTerminalProps {
}
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false, onClose, initialPath, executeCommand },
{
hostConfig,
isVisible,
splitScreen = false,
onClose,
initialPath,
executeCommand,
},
ref,
) {
const { t } = useTranslation();
@@ -458,8 +465,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Add macOS-specific keyboard event handling for special characters
const handleMacKeyboard = (e: KeyboardEvent) => {
// Detect macOS
const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ||
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
const isMacOS =
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
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
const keyMappings: { [key: string]: string } = {
// Using e.key values
'7': '|', // Option+7 = pipe symbol
'2': '€', // Option+2 = euro symbol
'8': '[', // Option+8 = left bracket
'9': ']', // Option+9 = right bracket
'l': '@', // Option+L = at symbol
'L': '@', // Option+L = at symbol (uppercase)
"7": "|", // Option+7 = pipe symbol
"2": "€", // Option+2 = euro symbol
"8": "[", // Option+8 = left bracket
"9": "]", // Option+9 = right bracket
l: "@", // Option+L = at symbol
L: "@", // Option+L = at symbol (uppercase)
// Using e.code values as fallback
'Digit7': '|', // Option+7 = pipe symbol
'Digit2': '€', // Option+2 = euro symbol
'Digit8': '[', // Option+8 = left bracket
'Digit9': ']', // Option+9 = right bracket
'KeyL': '@', // Option+L = at symbol
Digit7: "|", // Option+7 = pipe symbol
Digit2: "€", // Option+2 = euro symbol
Digit8: "[", // Option+8 = left bracket
Digit9: "]", // Option+9 = right bracket
KeyL: "@", // Option+L = at symbol
};
const char = keyMappings[e.key] || keyMappings[e.code];
@@ -489,7 +497,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Send the character directly to the terminal
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,266 +1,294 @@
<TabsContent value="key">
<div className="space-y-6">
{/* Private Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPrivateKey")}
</FormLabel>
<TabsContent value="key">
<div className="space-y-6">
{/* Private Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
field.onChange(file);
try {
const fileContent = await file.text();
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
} catch (error) {
console.error('Failed to read uploaded file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value instanceof File
? field.value.name
: t("credentials.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
field.onChange(file);
try {
const fileContent = await file.text();
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error("Failed to read uploaded file:", error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value instanceof File
? field.value.name
: t("credentials.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePrivateKey")}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === "string" ? field.value : ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Text Input */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePrivateKey")}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === "string" ? field.value : ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Key type detection display */}
{detectedKeyType && (
<div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedKeyType === 'invalid' || detectedKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span>
)}
</div>
)}
{/* Key type detection display */}
{detectedKeyType && (
<div className="text-sm">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" || detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)}
</div>
)}
{/* Show existing private key for editing */}
{editingCredential && fullCredentialDetails?.key && (
<FormItem>
<FormLabel>{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.key}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentKeyContent")}
</div>
{fullCredentialDetails?.detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">Key type: </span>
<span className="font-medium text-green-600">
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
</span>
</div>
)}
</FormItem>
)}
</div>
{/* Show existing private key for editing */}
{editingCredential && fullCredentialDetails?.key && (
<FormItem>
<FormLabel>
{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.key}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentKeyContent")}
</div>
{fullCredentialDetails?.detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">Key type: </span>
<span className="font-medium text-green-600">
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
</span>
</div>
)}
</FormItem>
)}
</div>
{/* Public Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
{/* Public Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error('Failed to read uploaded public key file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value ? t("credentials.publicKeyUploaded") : t("credentials.uploadPublicKey")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value
? t("credentials.publicKeyUploaded")
: t("credentials.uploadPublicKey")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Text Input */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Public key type detection */}
{detectedPublicKeyType && form.watch("publicKey") && (
<div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span>
)}
</div>
)}
{/* Public key type detection */}
{detectedPublicKeyType && form.watch("publicKey") && (
<div className="text-sm">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{t("credentials.publicKeyNote")}
</div>
<div className="text-xs text-muted-foreground">
{t("credentials.publicKeyNote")}
</div>
{/* Show existing public key for editing */}
{editingCredential && fullCredentialDetails?.publicKey && (
<FormItem>
<FormLabel>{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.publicKey}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentPublicKeyContent")}
</div>
</FormItem>
)}
</div>
{/* Show existing public key for editing */}
{editingCredential && fullCredentialDetails?.publicKey && (
<FormItem>
<FormLabel>
{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.publicKey}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentPublicKeyContent")}
</div>
</FormItem>
)}
</div>
{/* Generate Public Key Button */}
{form.watch("key") && (
<div className="mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeneratePublicKey}
disabled={generatePublicKeyLoading}
className="w-full"
>
{generatePublicKeyLoading ? (
<>
<span className="mr-2">{t("credentials.generating")}...</span>
</>
) : (
<>
<span>{t("credentials.generatePublicKey")}</span>
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{t("credentials.generatePublicKeyNote")}
</p>
</div>
)}
</div>
</TabsContent>
{/* Generate Public Key Button */}
{form.watch("key") && (
<div className="mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeneratePublicKey}
disabled={generatePublicKeyLoading}
className="w-full"
>
{generatePublicKeyLoading ? (
<>
<span className="mr-2">{t("credentials.generating")}...</span>
</>
) : (
<>
<span>{t("credentials.generatePublicKey")}</span>
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{t("credentials.generatePublicKeyNote")}
</p>
</div>
)}
</div>
</TabsContent>;