Cleanup files and improve file manager.
This commit is contained in:
14
.github/workflows/electron-build.yml
vendored
14
.github/workflows/electron-build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
12
SECURITY.md
12
SECURITY.md
@@ -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.**
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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++;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
src/types/electron.d.ts
vendored
6
src/types/electron.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (> 10MB) and cannot be opened as text for security reasons.
|
||||
File is too large (> 10MB) and cannot be opened as
|
||||
text for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Do you want to continue opening this file as text? This may slow down your browser.
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, // 直接调用版本
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user