v1.7.0 #318
107
.dockerignore
Normal file
107
.dockerignore
Normal file
@@ -0,0 +1,107 @@
|
||||
# Git and version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment and configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Build directories (we build inside Docker)
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# SSL certificates (generated at runtime)
|
||||
ssl/
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# Database files (use volumes)
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Docker files (avoid recursion)
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README*.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
*.md
|
||||
|
||||
# Repository images and assets (not needed in container)
|
||||
repo-images/
|
||||
|
||||
# Testing
|
||||
test/
|
||||
tests/
|
||||
*.test.js
|
||||
*.spec.js
|
||||
|
||||
# Uploads directory (use volumes)
|
||||
uploads/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Cache directories
|
||||
.cache/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
# Prettier
|
||||
.prettierignore
|
||||
.prettierrc*
|
||||
|
||||
# Local configuration
|
||||
.claude/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ dist-ssr
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
/ssl/
|
||||
.env
|
||||
|
||||
323
MIGRATION-TESTING.md
Normal file
323
MIGRATION-TESTING.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Database Migration Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the testing procedures for the automatic database migration system that migrates unencrypted SQLite databases to encrypted format during Docker deployment updates.
|
||||
|
||||
## Migration System Features
|
||||
|
||||
✅ **Automatic Detection**: Detects unencrypted databases on startup
|
||||
✅ **Safe Backup**: Creates timestamped backups before migration
|
||||
✅ **Integrity Verification**: Validates migration completeness
|
||||
✅ **Non-destructive**: Original files are renamed, not deleted
|
||||
✅ **Cleanup**: Removes old backup files (keeps latest 3)
|
||||
✅ **Admin API**: Migration status and history endpoints
|
||||
✅ **Detailed Logging**: Comprehensive migration logs
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Fresh Installation (No Migration Needed)
|
||||
**Setup**: Clean Docker container with no existing database files
|
||||
**Expected**:
|
||||
- New encrypted database created
|
||||
- No migration messages in logs
|
||||
- Status API shows "Fresh installation detected"
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# Clean start
|
||||
docker run --rm termix:latest
|
||||
# Check logs for "fresh installation"
|
||||
# GET /database/migration/status should show needsMigration: false
|
||||
```
|
||||
|
||||
### Scenario 2: Standard Migration (Unencrypted → Encrypted)
|
||||
**Setup**: Existing unencrypted `db.sqlite` file with user data
|
||||
**Expected**:
|
||||
- Automatic migration on startup
|
||||
- Backup file created (`.migration-backup-{timestamp}`)
|
||||
- Original file renamed (`.migrated-{timestamp}`)
|
||||
- Encrypted database created successfully
|
||||
- All data preserved and accessible
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# 1. Create test data in unencrypted format
|
||||
docker run -v /host/data:/app/data termix:old-version
|
||||
# Add some SSH hosts and credentials via UI
|
||||
|
||||
# 2. Stop container and update to new version
|
||||
docker stop container_id
|
||||
docker run -v /host/data:/app/data termix:latest
|
||||
|
||||
# 3. Check migration logs
|
||||
docker logs container_id | grep -i migration
|
||||
|
||||
# 4. Verify data integrity via API
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8081/database/migration/status
|
||||
```
|
||||
|
||||
### Scenario 3: Already Encrypted (No Migration Needed)
|
||||
**Setup**: Only encrypted database file exists
|
||||
**Expected**:
|
||||
- No migration performed
|
||||
- Database loads normally
|
||||
- Status API shows "Only encrypted database exists"
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# Start with existing encrypted database
|
||||
docker run -v /host/encrypted-data:/app/data termix:latest
|
||||
# Verify no migration messages in logs
|
||||
```
|
||||
|
||||
### Scenario 4: Both Files Exist (Safety Mode)
|
||||
**Setup**: Both encrypted and unencrypted databases present
|
||||
**Expected**:
|
||||
- Migration skipped for safety
|
||||
- Warning logged about manual intervention
|
||||
- Both files preserved
|
||||
- Uses encrypted database
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# Manually create both files
|
||||
touch /host/data/db.sqlite
|
||||
touch /host/data/db.sqlite.encrypted
|
||||
docker run -v /host/data:/app/data termix:latest
|
||||
# Check for safety warning in logs
|
||||
```
|
||||
|
||||
### Scenario 5: Migration Failure Recovery
|
||||
**Setup**: Simulate migration failure (corrupted source file)
|
||||
**Expected**:
|
||||
- Migration fails safely
|
||||
- Backup file preserved
|
||||
- Original unencrypted file untouched
|
||||
- Clear error message with recovery instructions
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# Create corrupted database file
|
||||
echo "corrupted" > /host/data/db.sqlite
|
||||
docker run -v /host/data:/app/data termix:latest
|
||||
# Verify error handling and backup preservation
|
||||
```
|
||||
|
||||
### Scenario 6: Large Database Migration
|
||||
**Setup**: Large unencrypted database (>100MB with many records)
|
||||
**Expected**:
|
||||
- Migration completes successfully
|
||||
- Performance is acceptable (under 30 seconds)
|
||||
- Memory usage stays reasonable
|
||||
- All data integrity checks pass
|
||||
|
||||
**Test Commands**:
|
||||
```bash
|
||||
# Create large dataset first
|
||||
# Monitor migration duration and memory usage
|
||||
docker stats container_id
|
||||
```
|
||||
|
||||
## API Testing
|
||||
|
||||
### Migration Status Endpoint
|
||||
```bash
|
||||
# Admin access required
|
||||
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
http://localhost:8081/database/migration/status
|
||||
|
||||
# Expected response:
|
||||
{
|
||||
"migrationStatus": {
|
||||
"needsMigration": false,
|
||||
"hasUnencryptedDb": false,
|
||||
"hasEncryptedDb": true,
|
||||
"unencryptedDbSize": 0,
|
||||
"reason": "Only encrypted database exists. No migration needed."
|
||||
},
|
||||
"files": {
|
||||
"unencryptedDbSize": 0,
|
||||
"encryptedDbSize": 524288,
|
||||
"backupFiles": 2,
|
||||
"migratedFiles": 1
|
||||
},
|
||||
"recommendations": [
|
||||
"Database is properly encrypted",
|
||||
"No action required"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Migration History Endpoint
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
http://localhost:8081/database/migration/history
|
||||
|
||||
# Expected response:
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"name": "db.sqlite.migration-backup-2024-09-24T10-30-00-000Z",
|
||||
"size": 262144,
|
||||
"created": "2024-09-24T10:30:00.000Z",
|
||||
"modified": "2024-09-24T10:30:00.000Z",
|
||||
"type": "backup"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalBackups": 1,
|
||||
"totalMigrated": 1,
|
||||
"oldestBackup": "2024-09-24T10:30:00.000Z",
|
||||
"newestBackup": "2024-09-24T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Log Analysis
|
||||
|
||||
### Successful Migration Logs
|
||||
Look for these log entries:
|
||||
```
|
||||
[INFO] Migration status check completed - needsMigration: true
|
||||
[INFO] Starting automatic database migration
|
||||
[INFO] Creating migration backup
|
||||
[SUCCESS] Migration backup created successfully
|
||||
[INFO] Found tables to migrate - tableCount: 8
|
||||
[SUCCESS] Migration integrity verification completed
|
||||
[INFO] Creating encrypted database file
|
||||
[SUCCESS] Database migration completed successfully
|
||||
```
|
||||
|
||||
### Migration Skipped (Safety) Logs
|
||||
```
|
||||
[INFO] Migration status check completed - needsMigration: false
|
||||
[INFO] Both encrypted and unencrypted databases exist. Skipping migration for safety
|
||||
[WARN] Manual intervention may be required
|
||||
```
|
||||
|
||||
### Migration Failure Logs
|
||||
```
|
||||
[ERROR] Database migration failed
|
||||
[ERROR] Backup available at: /app/data/db.sqlite.migration-backup-{timestamp}
|
||||
[ERROR] Manual intervention required to recover data
|
||||
```
|
||||
|
||||
## Manual Recovery Procedures
|
||||
|
||||
### If Migration Fails:
|
||||
1. **Locate backup file**: `db.sqlite.migration-backup-{timestamp}`
|
||||
2. **Restore original**: `cp backup-file db.sqlite`
|
||||
3. **Check logs**: Look for specific error details
|
||||
4. **Fix issue**: Address the root cause (permissions, disk space, etc.)
|
||||
5. **Retry**: Restart container to trigger migration again
|
||||
|
||||
### If Both Databases Exist:
|
||||
1. **Check dates**: Determine which file is newer
|
||||
2. **Backup both**: Make copies before proceeding
|
||||
3. **Remove older**: Delete the outdated database file
|
||||
4. **Restart**: Container will detect single database
|
||||
|
||||
### Emergency Data Recovery:
|
||||
1. **Backup files are SQLite**: Can be opened with any SQLite client
|
||||
2. **Manual export**: Use SQLite tools to export data
|
||||
3. **Re-import**: Use Termix import functionality
|
||||
|
||||
## Performance Expectations
|
||||
|
||||
| Database Size | Expected Migration Time | Memory Usage |
|
||||
|---------------|------------------------|--------------|
|
||||
| < 10MB | < 5 seconds | < 50MB |
|
||||
| 10-50MB | 5-15 seconds | < 100MB |
|
||||
| 50-200MB | 15-45 seconds | < 200MB |
|
||||
| 200MB+ | 45+ seconds | < 500MB |
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
After migration, verify:
|
||||
- [ ] All SSH hosts are accessible
|
||||
- [ ] SSH credentials work correctly
|
||||
- [ ] File manager recent/pinned items preserved
|
||||
- [ ] User settings maintained
|
||||
- [ ] OIDC configuration intact
|
||||
- [ ] Admin users still have admin privileges
|
||||
- [ ] Backup file exists and is valid SQLite
|
||||
- [ ] Original file renamed (not deleted)
|
||||
- [ ] Encrypted file is properly encrypted
|
||||
- [ ] Migration APIs respond correctly
|
||||
|
||||
## Monitoring Commands
|
||||
|
||||
```bash
|
||||
# Watch migration in real-time
|
||||
docker logs -f container_id | grep -i migration
|
||||
|
||||
# Check file sizes before/after
|
||||
ls -la /host/data/db.sqlite*
|
||||
|
||||
# Verify encrypted file
|
||||
file /host/data/db.sqlite.encrypted
|
||||
|
||||
# Monitor system resources during migration
|
||||
docker stats container_id
|
||||
|
||||
# Test database connectivity after migration
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8081/hosts/list
|
||||
```
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: "Permission denied" during backup creation
|
||||
**Solution**: Check container file permissions and volume mounts
|
||||
|
||||
### Issue: "Insufficient disk space" during migration
|
||||
**Solution**: Free up space, migration requires 2x database size temporarily
|
||||
|
||||
### Issue: "Database locked" error
|
||||
**Solution**: Ensure no other processes are accessing the database file
|
||||
|
||||
### Issue: Migration hangs indefinitely
|
||||
**Solution**: Check for very large BLOB data, increase timeout or migrate manually
|
||||
|
||||
### Issue: Encrypted file fails validation
|
||||
**Solution**: Check DATABASE_KEY environment variable, ensure it's stable
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Backup files contain unencrypted data**: Secure backup file access
|
||||
- **Migration logs may contain sensitive info**: Review log retention policies
|
||||
- **Temporary files during migration**: Ensure secure temp directory
|
||||
- **Original files are preserved**: Plan for secure cleanup of old files
|
||||
- **Admin API access**: Ensure proper authentication and authorization
|
||||
|
||||
## Integration with CI/CD
|
||||
|
||||
For automated testing in CI/CD pipelines:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Migration integration test
|
||||
set -e
|
||||
|
||||
# Start with unencrypted test data
|
||||
docker run -d --name test-migration \
|
||||
-v ./test-data:/app/data \
|
||||
termix:latest
|
||||
|
||||
# Wait for startup
|
||||
sleep 30
|
||||
|
||||
# Check migration status
|
||||
RESPONSE=$(curl -s -H "Authorization: Bearer $TEST_TOKEN" \
|
||||
http://localhost:8081/database/migration/status)
|
||||
|
||||
# Validate migration success
|
||||
echo "$RESPONSE" | jq '.migrationStatus.needsMigration == false'
|
||||
|
||||
# Cleanup
|
||||
docker stop test-migration
|
||||
docker rm test-migration
|
||||
```
|
||||
|
||||
This comprehensive testing approach ensures the migration system handles all edge cases safely and provides administrators with full visibility into the migration process.
|
||||
267
SECURITY.md
267
SECURITY.md
@@ -1,267 +0,0 @@
|
||||
# Security Guide for Termix
|
||||
|
||||
## Database Encryption
|
||||
|
||||
Termix implements AES-256-GCM encryption for sensitive data stored in the database. This protects SSH credentials, passwords, and authentication tokens from unauthorized access.
|
||||
|
||||
### Encrypted Fields
|
||||
|
||||
The following database fields are automatically encrypted:
|
||||
|
||||
**Users Table:**
|
||||
|
||||
- `password_hash` - User password hashes
|
||||
- `client_secret` - OIDC client secrets
|
||||
- `totp_secret` - 2FA authentication seeds
|
||||
- `totp_backup_codes` - 2FA backup codes
|
||||
|
||||
**SSH Data Table:**
|
||||
|
||||
- `password` - SSH connection passwords
|
||||
- `key` - SSH private keys
|
||||
- `keyPassword` - SSH private key passphrases
|
||||
|
||||
**SSH Credentials Table:**
|
||||
|
||||
- `password` - Stored SSH passwords
|
||||
- `privateKey` - SSH private keys
|
||||
- `keyPassword` - SSH private key passphrases
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Encryption master key (REQUIRED)
|
||||
DB_ENCRYPTION_KEY=your-very-strong-encryption-key-32-chars-minimum
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL:** The encryption key must be:
|
||||
|
||||
- At least 16 characters long (32+ recommended)
|
||||
- Cryptographically random
|
||||
- Unique per installation
|
||||
- Safely backed up
|
||||
|
||||
#### Optional Settings
|
||||
|
||||
```bash
|
||||
# Enable/disable encryption (default: true)
|
||||
ENCRYPTION_ENABLED=true
|
||||
|
||||
# Reject unencrypted data (default: false)
|
||||
FORCE_ENCRYPTION=false
|
||||
|
||||
# Auto-encrypt legacy data (default: true)
|
||||
MIGRATE_ON_ACCESS=true
|
||||
```
|
||||
|
||||
### Initial Setup
|
||||
|
||||
#### 1. Generate Encryption Key
|
||||
|
||||
```bash
|
||||
# Generate a secure random key (Linux/macOS)
|
||||
openssl rand -hex 32
|
||||
|
||||
# Or using Node.js
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
#### 2. Set Environment Variable
|
||||
|
||||
```bash
|
||||
# Add to your .env file
|
||||
echo "DB_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
#### 3. Validate Configuration
|
||||
|
||||
```bash
|
||||
# Test encryption setup
|
||||
npm run test:encryption
|
||||
```
|
||||
|
||||
### Migration from Unencrypted Database
|
||||
|
||||
If you have an existing Termix installation with unencrypted data:
|
||||
|
||||
#### 1. Backup Your Database
|
||||
|
||||
```bash
|
||||
# Create backup before migration
|
||||
cp ./db/data/db.sqlite ./db/data/db-backup-$(date +%Y%m%d-%H%M%S).sqlite
|
||||
```
|
||||
|
||||
#### 2. Run Migration
|
||||
|
||||
```bash
|
||||
# Set encryption key
|
||||
export DB_ENCRYPTION_KEY="your-secure-key-here"
|
||||
|
||||
# Test migration (dry run)
|
||||
npm run migrate:encryption -- --dry-run
|
||||
|
||||
# Run actual migration
|
||||
npm run migrate:encryption
|
||||
```
|
||||
|
||||
#### 3. Verify Migration
|
||||
|
||||
```bash
|
||||
# Check encryption status
|
||||
curl http://localhost:8081/encryption/status
|
||||
|
||||
# Test application functionality
|
||||
npm run test:encryption production
|
||||
```
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
#### Key Management
|
||||
|
||||
1. **Generate unique keys** for each installation
|
||||
2. **Store keys securely** (use environment variables, not config files)
|
||||
3. **Backup keys safely** (encrypted backups in secure locations)
|
||||
4. **Rotate keys periodically** (implement key rotation schedule)
|
||||
|
||||
#### Deployment Security
|
||||
|
||||
```bash
|
||||
# Production Docker example
|
||||
docker run -d \
|
||||
-e DB_ENCRYPTION_KEY="$(cat /secure/location/encryption.key)" \
|
||||
-e ENCRYPTION_ENABLED=true \
|
||||
-e FORCE_ENCRYPTION=true \
|
||||
-v termix-data:/app/data \
|
||||
ghcr.io/lukegus/termix:latest
|
||||
```
|
||||
|
||||
#### File System Protection
|
||||
|
||||
```bash
|
||||
# Secure database directory permissions
|
||||
chmod 700 ./db/data/
|
||||
chmod 600 ./db/data/db.sqlite
|
||||
|
||||
# Use encrypted storage if possible
|
||||
# Consider full disk encryption for production
|
||||
```
|
||||
|
||||
### Monitoring and Alerting
|
||||
|
||||
#### Health Checks
|
||||
|
||||
The encryption system provides health check endpoints:
|
||||
|
||||
```bash
|
||||
# Check encryption status
|
||||
GET /encryption/status
|
||||
|
||||
# Response format:
|
||||
{
|
||||
"encryption": {
|
||||
"enabled": true,
|
||||
"configValid": true,
|
||||
"forceEncryption": false,
|
||||
"migrateOnAccess": true
|
||||
},
|
||||
"migration": {
|
||||
"isEncryptionEnabled": true,
|
||||
"migrationCompleted": true,
|
||||
"migrationDate": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Log Monitoring
|
||||
|
||||
Monitor logs for encryption-related events:
|
||||
|
||||
```bash
|
||||
# Encryption initialization
|
||||
"Database encryption initialized successfully"
|
||||
|
||||
# Migration events
|
||||
"Migration completed for table: users"
|
||||
|
||||
# Security warnings
|
||||
"DB_ENCRYPTION_KEY not set, using default (INSECURE)"
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**1. "Decryption failed" errors**
|
||||
|
||||
- Verify `DB_ENCRYPTION_KEY` is correct
|
||||
- Check if database was corrupted
|
||||
- Restore from backup if necessary
|
||||
|
||||
**2. Performance issues**
|
||||
|
||||
- Encryption adds ~1ms per operation
|
||||
- Consider disabling `MIGRATE_ON_ACCESS` after migration
|
||||
- Monitor CPU usage during large migrations
|
||||
|
||||
**3. Key rotation**
|
||||
|
||||
```bash
|
||||
# Generate new key
|
||||
NEW_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Update configuration
|
||||
# Note: Requires re-encryption of all data
|
||||
```
|
||||
|
||||
### Compliance Notes
|
||||
|
||||
This encryption implementation helps meet requirements for:
|
||||
|
||||
- **GDPR** - Personal data protection
|
||||
- **SOC 2** - Data security controls
|
||||
- **PCI DSS** - Sensitive data protection
|
||||
- **HIPAA** - Healthcare data encryption (if applicable)
|
||||
|
||||
### Security Limitations
|
||||
|
||||
**What this protects against:**
|
||||
|
||||
- Database file theft
|
||||
- Disk access by unauthorized users
|
||||
- Data breaches from file system access
|
||||
|
||||
**What this does NOT protect against:**
|
||||
|
||||
- Application-level vulnerabilities
|
||||
- Memory dumps while application is running
|
||||
- Attacks against the running application
|
||||
- Social engineering attacks
|
||||
|
||||
### Emergency Procedures
|
||||
|
||||
#### Lost Encryption Key
|
||||
|
||||
⚠️ **Data is unrecoverable without the encryption key**
|
||||
|
||||
1. Check all backup locations
|
||||
2. Restore from unencrypted backup if available
|
||||
3. Contact system administrators
|
||||
|
||||
#### Suspected Key Compromise
|
||||
|
||||
1. **Immediately** generate new encryption key
|
||||
2. Take application offline
|
||||
3. Re-encrypt all sensitive data with new key
|
||||
4. Investigate compromise source
|
||||
5. Update security procedures
|
||||
|
||||
### Support
|
||||
|
||||
For security-related questions:
|
||||
|
||||
- Open issue: [GitHub Issues](https://github.com/LukeGus/Termix/issues)
|
||||
- Discord: [Termix Community](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
**Do not share encryption keys or sensitive debugging information in public channels.**
|
||||
52
docker/.env.example
Normal file
52
docker/.env.example
Normal file
@@ -0,0 +1,52 @@
|
||||
# Termix Docker Environment Configuration Example
|
||||
#
|
||||
# IMPORTANT: This file shows available environment variables.
|
||||
# For most users, you DON'T need to create a .env file.
|
||||
# Termix will auto-generate secure keys on first startup.
|
||||
#
|
||||
# Copy this file to .env ONLY if you need custom configuration:
|
||||
# cp docker/.env.example docker/.env
|
||||
|
||||
# ===== BASIC CONFIGURATION =====
|
||||
PORT=8080
|
||||
NODE_ENV=production
|
||||
|
||||
# ===== SSL/HTTPS CONFIGURATION =====
|
||||
ENABLE_SSL=false
|
||||
SSL_PORT=8443
|
||||
SSL_DOMAIN=localhost
|
||||
SSL_CERT_PATH=/app/ssl/termix.crt
|
||||
SSL_KEY_PATH=/app/ssl/termix.key
|
||||
|
||||
# ===== SECURITY KEYS =====
|
||||
# WARNING: Only set these if you need specific keys for multi-instance deployment
|
||||
# For single instance deployment, leave these EMPTY - Termix will auto-generate
|
||||
# secure random keys and persist them in Docker volumes.
|
||||
#
|
||||
# If you DO set these, generate them with: openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
DATABASE_KEY=
|
||||
INTERNAL_AUTH_TOKEN=
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
DATABASE_ENCRYPTION=true
|
||||
|
||||
# ===== CORS CONFIGURATION =====
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# ===== DEPLOYMENT NOTES =====
|
||||
#
|
||||
# Single Instance (Recommended):
|
||||
# - Don't create .env file - let Termix auto-generate keys
|
||||
# - Keys are automatically persisted in Docker volumes
|
||||
# - Secure and maintenance-free
|
||||
#
|
||||
# Multi-Instance Cluster:
|
||||
# - Set identical JWT_SECRET, DATABASE_KEY, INTERNAL_AUTH_TOKEN across all instances
|
||||
# - Use shared storage for /app/data and /app/config volumes
|
||||
# - Ensure all instances can access the same encryption keys
|
||||
#
|
||||
# Security Best Practices:
|
||||
# - Never commit .env files to version control
|
||||
# - Use Docker secrets in production environments
|
||||
# - Regularly rotate keys (requires data migration)
|
||||
@@ -35,24 +35,11 @@ RUN npm rebuild better-sqlite3 --force
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
# Stage 4: Production dependencies with native modules
|
||||
FROM node:24-alpine AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:24-alpine AS native-builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -60,10 +47,11 @@ ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
# Install native modules and compile them properly
|
||||
RUN npm ci --only=production --force && \
|
||||
# Install production dependencies and rebuild native modules in one stage
|
||||
RUN npm ci --omit=dev --ignore-scripts --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
npm cache clean --force
|
||||
npm cache clean --force && \
|
||||
rm -rf ~/.npm /tmp/* /var/cache/apk/*
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:24-alpine
|
||||
@@ -71,23 +59,26 @@ ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache nginx gettext su-exec && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R node:node /app/data
|
||||
RUN apk add --no-cache nginx gettext su-exec openssl && \
|
||||
mkdir -p /app/data /app/config /app/ssl && \
|
||||
chown -R node:node /app/data /app/config /app/ssl
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=native-builder /app/node_modules /app/node_modules
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
COPY package.json ./
|
||||
COPY .env ./.env
|
||||
RUN chown -R node:node /app
|
||||
RUN chown -R node:node /app && \
|
||||
chmod 755 /app/config && \
|
||||
chmod 755 /app/ssl && \
|
||||
chmod 755 /app/data
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
|
||||
@@ -1,15 +1,67 @@
|
||||
# Termix Docker Compose Configuration
|
||||
#
|
||||
# QUICK START: Just run "docker-compose up -d"
|
||||
# - Security keys are auto-generated on first startup
|
||||
# - Keys are persisted in Docker volumes (survive container restarts)
|
||||
# - No manual .env file needed for single-instance deployment
|
||||
#
|
||||
# See docker/.env.example for advanced configuration options
|
||||
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
# HTTP port (redirects to HTTPS if SSL enabled)
|
||||
- "${PORT:-8080}:8080"
|
||||
# HTTPS port (when SSL is enabled)
|
||||
- "${SSL_PORT:-8443}:8443"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
- termix-config:/app/config # Auto-generated .env keys are persisted here
|
||||
# Optional: Mount custom SSL certificates
|
||||
# - ./ssl:/app/ssl:ro
|
||||
environment:
|
||||
PORT: "8080"
|
||||
# Basic configuration
|
||||
- PORT=${PORT:-8080}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
|
||||
# SSL/TLS Configuration
|
||||
- ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
- SSL_PORT=${SSL_PORT:-8443}
|
||||
- SSL_DOMAIN=${SSL_DOMAIN:-localhost}
|
||||
- SSL_CERT_PATH=${SSL_CERT_PATH:-/app/ssl/termix.crt}
|
||||
- SSL_KEY_PATH=${SSL_KEY_PATH:-/app/ssl/termix.key}
|
||||
|
||||
# Security keys (auto-generated if not provided)
|
||||
# Leave empty to auto-generate secure random keys on first startup
|
||||
# Set values only if you need specific keys for multi-instance deployment
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
- DATABASE_KEY=${DATABASE_KEY:-}
|
||||
- INTERNAL_AUTH_TOKEN=${INTERNAL_AUTH_TOKEN:-}
|
||||
|
||||
# Database configuration
|
||||
- DATABASE_ENCRYPTION=${DATABASE_ENCRYPTION:-true}
|
||||
|
||||
# CORS configuration
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
|
||||
# Health check for both HTTP and HTTPS
|
||||
healthcheck:
|
||||
test: |
|
||||
curl -f -k https://localhost:8443/health 2>/dev/null ||
|
||||
curl -f http://localhost:8080/health 2>/dev/null ||
|
||||
exit 1
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
termix-config:
|
||||
driver: local
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
set -e
|
||||
|
||||
export PORT=${PORT:-8080}
|
||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/ssl/termix.crt}
|
||||
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/ssl/termix.key}
|
||||
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
||||
# Choose nginx configuration based on SSL setting
|
||||
# Default: HTTP-only for easy setup
|
||||
# Set ENABLE_SSL=true to use HTTPS with automatic redirect
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "SSL enabled - using HTTPS configuration with redirect"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
||||
else
|
||||
echo "SSL disabled - using HTTP-only configuration (default)"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
|
||||
fi
|
||||
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
|
||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||
|
||||
mkdir -p /app/data
|
||||
|
||||
211
docker/nginx-https.conf
Normal file
211
docker/nginx-https.conf
Normal file
@@ -0,0 +1,211 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# HTTP Server - Redirect to HTTPS
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name localhost;
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$server_name:${SSL_PORT}$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen ${SSL_PORT} ssl;
|
||||
server_name localhost;
|
||||
|
||||
# SSL Certificate paths
|
||||
ssl_certificate ${SSL_CERT_PATH};
|
||||
ssl_certificate_key ${SSL_KEY_PATH};
|
||||
|
||||
# Security headers for HTTPS
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy for authenticated terminal connections
|
||||
location /ssh/websocket/ {
|
||||
# Pass to WebSocket server with authentication support
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Pass client information for authentication logging
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Query parameters are passed by default with proxy_pass
|
||||
|
||||
# WebSocket timeouts (longer for terminal sessions)
|
||||
proxy_read_timeout 86400s; # 24 hours
|
||||
proxy_send_timeout 86400s; # 24 hours
|
||||
proxy_connect_timeout 10s; # Quick auth check
|
||||
|
||||
# Disable buffering for real-time terminal
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Handle connection errors gracefully
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:8083;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,23 @@ http {
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# HTTP Server - Redirect to HTTPS when SSL enabled
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name localhost;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
@@ -72,25 +85,36 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy for authenticated terminal connections
|
||||
location /ssh/websocket/ {
|
||||
# Pass to WebSocket server with authentication support
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Pass client information for authentication logging
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Query parameters are passed by default with proxy_pass
|
||||
|
||||
# WebSocket timeouts (longer for terminal sessions)
|
||||
proxy_read_timeout 86400s; # 24 hours
|
||||
proxy_send_timeout 86400s; # 24 hours
|
||||
proxy_connect_timeout 10s; # Quick auth check
|
||||
|
||||
# Disable buffering for real-time terminal
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Handle connection errors gracefully
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
|
||||
@@ -23,21 +23,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
|
||||
// ================== 拖拽API ==================
|
||||
// ================== Drag & Drop API ==================
|
||||
|
||||
// 创建临时文件用于拖拽
|
||||
// Create temporary file for dragging
|
||||
createTempFile: (fileData) =>
|
||||
ipcRenderer.invoke("create-temp-file", fileData),
|
||||
|
||||
// 创建临时文件夹用于拖拽
|
||||
// Create temporary folder for dragging
|
||||
createTempFolder: (folderData) =>
|
||||
ipcRenderer.invoke("create-temp-folder", folderData),
|
||||
|
||||
// 开始拖拽到桌面
|
||||
// Start dragging to desktop
|
||||
startDragToDesktop: (dragData) =>
|
||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||
|
||||
// 清理临时文件
|
||||
// Cleanup temporary files
|
||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||
});
|
||||
|
||||
|
||||
2696
package-lock.json
generated
2696
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -9,9 +9,12 @@
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"dev:https": "cross-env VITE_HTTPS=true vite",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"start": "npm run build:backend && node ./dist/backend/backend/starter.js",
|
||||
"start:ssl": "npm run start",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"electron": "electron .",
|
||||
@@ -23,6 +26,9 @@
|
||||
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/comment": "^0.19.1",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -81,15 +87,23 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-player": "^3.3.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
@@ -113,6 +127,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
|
||||
29
public/pdf.worker.min.js
vendored
Normal file
29
public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
176
scripts/enable-ssl.sh
Normal file
176
scripts/enable-ssl.sh
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Termix SSL Quick Setup Script
|
||||
# Enables HTTPS/WSS with one command
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
echo -e "${CYAN}$1${NC}"
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
log_header "🔒 Termix SSL Quick Setup"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
log_info "This script will:"
|
||||
echo " ✅ Generate SSL certificates automatically"
|
||||
echo " ✅ Create/update .env configuration"
|
||||
echo " ✅ Enable HTTPS/WSS support"
|
||||
echo " ✅ Generate security keys"
|
||||
echo ""
|
||||
}
|
||||
|
||||
generate_keys() {
|
||||
log_info "🔑 Generating security keys..."
|
||||
|
||||
# Generate JWT secret
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
log_success "Generated JWT secret"
|
||||
|
||||
# Generate database key
|
||||
DATABASE_KEY=$(openssl rand -hex 32)
|
||||
log_success "Generated database encryption key"
|
||||
|
||||
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
|
||||
echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE"
|
||||
|
||||
log_success "Security keys added to .env file"
|
||||
}
|
||||
|
||||
setup_env_file() {
|
||||
log_info "📝 Setting up environment configuration..."
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
log_warn "⚠️ .env file already exists, creating backup..."
|
||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
|
||||
fi
|
||||
|
||||
# Create or update .env file
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# Termix SSL Configuration - Auto-generated $(date)
|
||||
|
||||
# SSL/TLS Configuration
|
||||
ENABLE_SSL=true
|
||||
SSL_PORT=8443
|
||||
SSL_DOMAIN=localhost
|
||||
PORT=8080
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
|
||||
# CORS configuration
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Database encryption
|
||||
DATABASE_ENCRYPTION=true
|
||||
|
||||
EOF
|
||||
|
||||
# Add security keys
|
||||
generate_keys
|
||||
|
||||
log_success "Environment configuration created at $ENV_FILE"
|
||||
}
|
||||
|
||||
setup_ssl_certificates() {
|
||||
log_info "🔐 Setting up SSL certificates..."
|
||||
|
||||
# Run SSL setup script
|
||||
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
|
||||
bash "$SCRIPT_DIR/setup-ssl.sh"
|
||||
else
|
||||
log_error "❌ SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_next_steps() {
|
||||
echo ""
|
||||
log_header "🚀 SSL Setup Complete!"
|
||||
echo ""
|
||||
log_success "Your Termix instance is now configured for HTTPS/WSS!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. 🐳 Using Docker:"
|
||||
echo " docker-compose -f docker-compose.ssl.yml up"
|
||||
echo ""
|
||||
echo "2. 📦 Using npm:"
|
||||
echo " npm start"
|
||||
echo ""
|
||||
echo "3. 🌐 Access your application:"
|
||||
echo " • HTTPS: https://localhost:8443"
|
||||
echo " • HTTP: http://localhost:8080 (redirects to HTTPS)"
|
||||
echo ""
|
||||
echo "4. 📱 WebSocket connections will automatically use WSS"
|
||||
echo ""
|
||||
log_warn "⚠️ Browser Warning: Self-signed certificates will show security warnings"
|
||||
echo ""
|
||||
echo "For production deployment:"
|
||||
echo "• Replace self-signed certificates with CA-signed certificates"
|
||||
echo "• Update SSL_DOMAIN in .env to your actual domain"
|
||||
echo "• Set proper ALLOWED_ORIGINS for CORS"
|
||||
echo ""
|
||||
|
||||
# Show generated keys
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
echo "Generated security keys (keep these secure!):"
|
||||
echo "• JWT_SECRET: $(grep JWT_SECRET "$ENV_FILE" | cut -d= -f2)"
|
||||
echo "• DATABASE_KEY: $(grep DATABASE_KEY "$ENV_FILE" | cut -d= -f2)"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
print_banner
|
||||
|
||||
# Check prerequisites
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "❌ OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Setup environment
|
||||
setup_env_file
|
||||
|
||||
# Setup SSL certificates
|
||||
setup_ssl_certificates
|
||||
|
||||
# Show completion message
|
||||
show_next_steps
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
195
scripts/setup-ssl.sh
Normal file
195
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Termix SSL Certificate Auto-Setup Script
|
||||
# Linus principle: Simple, automatic, works everywhere
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SSL_DIR="$(dirname "$0")/../ssl"
|
||||
CERT_FILE="$SSL_DIR/termix.crt"
|
||||
KEY_FILE="$SSL_DIR/termix.key"
|
||||
DAYS_VALID=365
|
||||
|
||||
# Default domain - can be overridden by environment variable
|
||||
DOMAIN=${SSL_DOMAIN:-"localhost"}
|
||||
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if certificate exists and is still valid
|
||||
check_existing_cert() {
|
||||
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
|
||||
# Check if certificate is still valid for at least 30 days
|
||||
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
|
||||
log_success "✅ Valid SSL certificate already exists"
|
||||
log_info "Certificate: $CERT_FILE"
|
||||
log_info "Private Key: $KEY_FILE"
|
||||
|
||||
# Show certificate info
|
||||
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
log_info "Expires: $expiry"
|
||||
return 0
|
||||
else
|
||||
log_warn "⚠️ Existing certificate is expired or expiring soon"
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Generate self-signed certificate
|
||||
generate_certificate() {
|
||||
log_info "🔐 Generating new SSL certificate for domain: $DOMAIN"
|
||||
|
||||
# Create SSL directory if it doesn't exist
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
# Create OpenSSL config for SAN (Subject Alternative Names)
|
||||
local config_file="$SSL_DIR/openssl.conf"
|
||||
cat > "$config_file" << EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=$DOMAIN
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
IP.1 = 127.0.0.1
|
||||
EOF
|
||||
|
||||
# Add custom alt names if provided
|
||||
if [[ -n "$SSL_ALT_NAMES" ]]; then
|
||||
local counter=2
|
||||
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
|
||||
for name in "${NAMES[@]}"; do
|
||||
name=$(echo "$name" | xargs) # trim whitespace
|
||||
if [[ "$name" == DNS:* ]]; then
|
||||
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
|
||||
elif [[ "$name" == IP:* ]]; then
|
||||
echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Generate private key
|
||||
log_info "📝 Generating private key..."
|
||||
openssl genrsa -out "$KEY_FILE" 2048
|
||||
|
||||
# Generate certificate
|
||||
log_info "📄 Generating certificate..."
|
||||
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 "$KEY_FILE"
|
||||
chmod 644 "$CERT_FILE"
|
||||
|
||||
# Clean up temp config
|
||||
rm -f "$config_file"
|
||||
|
||||
log_success "✅ SSL certificate generated successfully"
|
||||
log_info "Certificate: $CERT_FILE"
|
||||
log_info "Private Key: $KEY_FILE"
|
||||
log_info "Valid for: $DAYS_VALID days"
|
||||
}
|
||||
|
||||
# Show certificate information
|
||||
show_certificate_info() {
|
||||
if [[ -f "$CERT_FILE" ]]; then
|
||||
echo ""
|
||||
log_info "📋 Certificate Information:"
|
||||
openssl x509 -in "$CERT_FILE" -noout -subject -issuer -dates
|
||||
|
||||
echo ""
|
||||
log_info "🌐 Subject Alternative Names:"
|
||||
openssl x509 -in "$CERT_FILE" -noout -text | grep -A1 "Subject Alternative Name" | tail -1 | sed 's/^[[:space:]]*//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo "🔒 Termix SSL Certificate Auto-Setup"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
log_info "Target domain: $DOMAIN"
|
||||
log_info "SSL directory: $SSL_DIR"
|
||||
|
||||
# Check if OpenSSL is available
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "❌ OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check existing certificate
|
||||
if check_existing_cert; then
|
||||
show_certificate_info
|
||||
echo ""
|
||||
log_info "🚀 SSL setup complete - ready for HTTPS/WSS!"
|
||||
echo ""
|
||||
echo "To use the certificate:"
|
||||
echo " - Nginx SSL cert: $CERT_FILE"
|
||||
echo " - Nginx SSL key: $KEY_FILE"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Generate new certificate
|
||||
generate_certificate
|
||||
show_certificate_info
|
||||
|
||||
echo ""
|
||||
log_success "🚀 SSL setup complete - ready for HTTPS/WSS!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Update your Nginx configuration to use these certificates"
|
||||
echo " 2. Restart Nginx to enable HTTPS/WSS"
|
||||
echo " 3. Access your application via https://localhost"
|
||||
echo ""
|
||||
|
||||
# Security note for self-signed certificates
|
||||
log_warn "⚠️ Note: Self-signed certificates will show browser warnings"
|
||||
log_info "💡 For production, consider using Let's Encrypt or a commercial CA"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
@@ -25,119 +28,198 @@ const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
let actualDbPath = ":memory:"; // Always use memory database
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database; // Module-level sqlite instance
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
// Async initialization function to handle SystemCrypto and DatabaseFileEncryption
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
// Initialize SystemCrypto database key first
|
||||
databaseLogger.info("Initializing SystemCrypto database key...", {
|
||||
operation: "db_init_systemcrypto",
|
||||
envKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
envKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
// Validate hardware compatibility
|
||||
if (
|
||||
!DatabaseFileEncryption.validateHardwareCompatibility(encryptedDbPath)
|
||||
) {
|
||||
databaseLogger.error(
|
||||
"Hardware fingerprint mismatch for encrypted database",
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
|
||||
// Verify key is available after initialization
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
databaseLogger.info("SystemCrypto database key initialized", {
|
||||
operation: "db_init_systemcrypto_complete",
|
||||
keyLength: dbKey.length,
|
||||
keyAvailable: !!dbKey,
|
||||
});
|
||||
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
// Check if encrypted database exists
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
databaseLogger.info(
|
||||
"Found encrypted database file, loading into memory...",
|
||||
{
|
||||
operation: "db_decrypt_failed",
|
||||
reason: "hardware_mismatch",
|
||||
operation: "db_memory_load",
|
||||
encryptedPath: encryptedDbPath,
|
||||
fileSize: fs.statSync(encryptedDbPath).size,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
"Cannot decrypt database: hardware fingerprint mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
// Decrypt database content to memory buffer
|
||||
const decryptedBuffer =
|
||||
DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
// Decrypt database content to memory buffer (now async)
|
||||
databaseLogger.info("Starting database decryption...", {
|
||||
operation: "db_decrypt_start",
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
|
||||
// Create in-memory database from decrypted buffer
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
// Check if there's an old unencrypted database to migrate
|
||||
if (fs.existsSync(dbPath)) {
|
||||
// Load old database and copy its content to memory database
|
||||
const oldDb = new Database(dbPath, { readonly: true });
|
||||
databaseLogger.info("Database decryption successful", {
|
||||
operation: "db_decrypt_success",
|
||||
decryptedSize: decryptedBuffer.length,
|
||||
isSqlite: decryptedBuffer.slice(0, 16).toString().startsWith('SQLite format 3'),
|
||||
});
|
||||
|
||||
// Get all table schemas and data from old database
|
||||
const tables = oldDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`,
|
||||
)
|
||||
.all() as { name: string; sql: string }[];
|
||||
// Create in-memory database from decrypted buffer
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
// Create tables in memory database
|
||||
for (const table of tables) {
|
||||
memoryDatabase.exec(table.sql);
|
||||
}
|
||||
|
||||
// Copy data for each table
|
||||
for (const table of tables) {
|
||||
const rows = oldDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insertStmt = memoryDatabase.prepare(
|
||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const values = columns.map((col) => (row as any)[col]);
|
||||
insertStmt.run(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldDb.close();
|
||||
|
||||
isNewDatabase = false;
|
||||
databaseLogger.info("In-memory database created from decrypted buffer", {
|
||||
operation: "db_memory_create_success",
|
||||
});
|
||||
} else {
|
||||
// No encrypted database exists - check if we need to migrate
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
|
||||
databaseLogger.info("Migration status check completed", {
|
||||
operation: "migration_status",
|
||||
needsMigration: migrationStatus.needsMigration,
|
||||
hasUnencryptedDb: migrationStatus.hasUnencryptedDb,
|
||||
hasEncryptedDb: migrationStatus.hasEncryptedDb,
|
||||
unencryptedDbSize: migrationStatus.unencryptedDbSize,
|
||||
reason: migrationStatus.reason,
|
||||
});
|
||||
|
||||
if (migrationStatus.needsMigration) {
|
||||
// Perform automatic migration
|
||||
databaseLogger.info("Starting automatic database migration", {
|
||||
operation: "auto_migration_start",
|
||||
unencryptedDbSize: migrationStatus.unencryptedDbSize,
|
||||
});
|
||||
|
||||
const migrationResult = await migration.migrateDatabase();
|
||||
|
||||
if (migrationResult.success) {
|
||||
databaseLogger.success("Automatic database migration completed successfully", {
|
||||
operation: "auto_migration_success",
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
|
||||
// Clean up old backup files
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
// Load the newly created encrypted database
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
databaseLogger.info("Loading migrated encrypted database into memory", {
|
||||
operation: "load_migrated_db",
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
|
||||
const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
isNewDatabase = false; // We have migrated data
|
||||
|
||||
databaseLogger.success("Migrated encrypted database loaded successfully", {
|
||||
operation: "load_migrated_db_success",
|
||||
decryptedSize: decryptedBuffer.length,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Migration completed but encrypted database file not found");
|
||||
}
|
||||
} else {
|
||||
// Migration failed - this is critical
|
||||
databaseLogger.error("Automatic database migration failed", null, {
|
||||
operation: "auto_migration_failed",
|
||||
error: migrationResult.error,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
|
||||
// 🔥 CRITICAL: Migration failure with existing data
|
||||
console.error("🚨 DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Migration error:", migrationResult.error);
|
||||
console.error("Backup available at:", migrationResult.backupPath);
|
||||
console.error("Manual intervention required to recover data.");
|
||||
|
||||
throw new Error(`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`);
|
||||
}
|
||||
} else {
|
||||
// No migration needed - create fresh database
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
|
||||
databaseLogger.info("Creating fresh in-memory database", {
|
||||
operation: "fresh_db_create",
|
||||
reason: migrationStatus.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
// If file encryption is critical, fail fast
|
||||
if (process.env.DB_FILE_ENCRYPTION_REQUIRED === "true") {
|
||||
throw error;
|
||||
}
|
||||
// 🔥 CRITICAL: Never silently ignore database decryption failures!
|
||||
// This causes complete data loss for users
|
||||
console.error("🚨 DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Error details:", error instanceof Error ? error.message : error);
|
||||
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
|
||||
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
|
||||
|
||||
// Always fail fast on decryption errors - data integrity is critical
|
||||
throw new Error(`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
// Main async initialization function that combines database setup with schema creation
|
||||
async function initializeCompleteDatabase(): Promise<void> {
|
||||
// First initialize the database and SystemCrypto
|
||||
await initializeDatabaseAsync();
|
||||
|
||||
const sqlite = memoryDatabase;
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
// Create module-level sqlite instance after database is initialized
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
// Initialize drizzle ORM with the configured database
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
databaseLogger.info("Database ORM initialized", {
|
||||
operation: "drizzle_init",
|
||||
tablesConfigured: Object.keys(schema).length
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
@@ -256,8 +338,36 @@ sqlite.exec(`
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
// Run schema migrations
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
@@ -365,11 +475,11 @@ const migrateSchema = () => {
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"require_password",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
// AutoStart plaintext columns
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
|
||||
|
||||
// SSH credentials table migrations for encryption support
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
@@ -385,115 +495,70 @@ const migrateSchema = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const initializeDatabase = async (): Promise<void> => {
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
} else {
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to save in-memory database to encrypted file
|
||||
// Function to save in-memory database to file (encrypted or unencrypted fallback)
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase || !enableFileEncryption) return;
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
// Encrypt and save to file
|
||||
DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
databaseLogger.info("Created data directory", {
|
||||
operation: "data_dir_create",
|
||||
path: dataDir,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
||||
operation: "memory_db_save",
|
||||
bufferSize: buffer.length,
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
if (enableFileEncryption) {
|
||||
// Save as encrypted file
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
||||
operation: "memory_db_save_encrypted",
|
||||
bufferSize: buffer.length,
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
} else {
|
||||
// Fallback: save as unencrypted SQLite file to prevent data loss
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to unencrypted file", {
|
||||
operation: "memory_db_save_unencrypted",
|
||||
bufferSize: buffer.length,
|
||||
unencryptedPath: dbPath,
|
||||
warning: "File encryption disabled - data saved unencrypted",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle post-initialization file encryption and cleanup
|
||||
// Function to handle post-initialization file encryption and periodic saves
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing unencrypted database files
|
||||
// Check for any remaining unencrypted database files that may need attention
|
||||
if (fs.existsSync(dbPath)) {
|
||||
// This could happen if migration was skipped or if there are multiple database files
|
||||
databaseLogger.warn(
|
||||
"Found unencrypted database file, removing for security",
|
||||
"Unencrypted database file still exists after initialization",
|
||||
{
|
||||
operation: "db_security_cleanup_existing",
|
||||
removingPath: dbPath,
|
||||
operation: "db_security_check",
|
||||
path: dbPath,
|
||||
note: "This may be normal if migration was skipped for safety reasons",
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Unencrypted database file removed for security",
|
||||
{
|
||||
operation: "db_security_cleanup_complete",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Could not remove unencrypted database file (may be locked)",
|
||||
{
|
||||
operation: "db_security_cleanup_deferred",
|
||||
path: dbPath,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
|
||||
// Try again after a short delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Delayed cleanup: unencrypted database file removed",
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_success",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (delayedError) {
|
||||
databaseLogger.error(
|
||||
"Failed to remove unencrypted database file even after delay",
|
||||
delayedError,
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_failed",
|
||||
path: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
// Don't automatically delete - let migration logic handle this
|
||||
// This provides better safety and transparency
|
||||
}
|
||||
|
||||
// Always save the in-memory database (whether new or existing)
|
||||
@@ -501,15 +566,35 @@ async function handlePostInitFileEncryption() {
|
||||
// Save immediately after initialization
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
// Set up periodic saves every 5 minutes
|
||||
setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000);
|
||||
databaseLogger.info("Setting up periodic database saves", {
|
||||
operation: "db_periodic_save_setup",
|
||||
interval: "15 seconds",
|
||||
});
|
||||
|
||||
// Set up periodic saves every 15 seconds for real-time persistence
|
||||
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||
|
||||
// Initialize database save trigger for real-time saves
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
// Perform migration cleanup on startup (remove old backup files)
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup old migration files", {
|
||||
operation: "migration_cleanup_startup_failed",
|
||||
error: cleanupError instanceof Error ? cleanupError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption/cleanup",
|
||||
"Failed to handle database file encryption setup",
|
||||
error,
|
||||
{
|
||||
operation: "db_encrypt_cleanup_failed",
|
||||
operation: "db_encrypt_setup_failed",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -517,8 +602,19 @@ async function handlePostInitFileEncryption() {
|
||||
}
|
||||
}
|
||||
|
||||
initializeDatabase()
|
||||
.then(() => handlePostInitFileEncryption())
|
||||
// Export a promise that resolves when database is fully initialized
|
||||
export const databaseReady = initializeCompleteDatabase()
|
||||
.then(async () => {
|
||||
await handlePostInitFileEncryption();
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
hasEncryptedBackup:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database", error, {
|
||||
operation: "db_init",
|
||||
@@ -526,14 +622,6 @@ initializeDatabase()
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
hasEncryptedBackup:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
});
|
||||
|
||||
// Cleanup function for database and temporary files
|
||||
async function cleanupDatabase() {
|
||||
// Save in-memory database before closing
|
||||
@@ -619,9 +707,27 @@ process.on("SIGTERM", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Export database connection and file encryption utilities
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export const sqliteInstance = sqlite; // Export underlying SQLite instance for schema queries
|
||||
// Database connection - will be initialized after database setup
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
// Export database connection getter function to avoid undefined access
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Ensure databaseReady promise is awaited before accessing db.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// Export raw SQLite instance for migrations
|
||||
export function getSqlite(): Database.Database {
|
||||
if (!sqlite) {
|
||||
throw new Error("SQLite not initialized. Ensure databaseReady promise is awaited before accessing sqlite.");
|
||||
}
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
// Legacy export for compatibility - will throw if accessed before initialization
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
@@ -660,3 +766,6 @@ function getMemoryDatabaseBuffer(): Buffer {
|
||||
|
||||
// Export save function for manual saves and buffer access
|
||||
export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer };
|
||||
|
||||
// Export database save trigger for real-time saves
|
||||
export { DatabaseSaveTrigger };
|
||||
|
||||
600
src/backend/database/db/old-index.ts.bak
Normal file
600
src/backend/database/db/old-index.ts.bak
Normal file
@@ -0,0 +1,600 @@
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { UserDatabaseManager } from "../../utils/user-database-manager.js";
|
||||
|
||||
// Global database manager instance
|
||||
const databaseManager = UserDatabaseManager.getInstance();
|
||||
|
||||
/**
|
||||
* Initialize database system - simplified for user-based architecture
|
||||
*/
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing database system (user-based architecture)", {
|
||||
operation: "db_init_v3",
|
||||
});
|
||||
|
||||
// Initialize system database (unencrypted)
|
||||
await databaseManager.initializeSystem();
|
||||
|
||||
databaseLogger.success("Database system initialized successfully", {
|
||||
operation: "db_init_v3_success",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database system", error, {
|
||||
operation: "db_init_v3_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a promise that resolves when database is fully initialized
|
||||
export const databaseReady = initializeDatabase()
|
||||
.then(() => {
|
||||
databaseLogger.success("Database system ready", {
|
||||
operation: "db_ready",
|
||||
architecture: "v3-user-based",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database system", error, {
|
||||
operation: "db_ready_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
// Create module-level sqlite instance after database is initialized
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
// Initialize drizzle ORM with the configured database
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
databaseLogger.info("Database ORM initialized", {
|
||||
operation: "drizzle_init",
|
||||
tablesConfigured: Object.keys(schema).length
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
issuer_url TEXT NOT NULL,
|
||||
authorization_url TEXT NOT NULL,
|
||||
token_url TEXT NOT NULL,
|
||||
redirect_uri TEXT,
|
||||
identifier_path TEXT NOT NULL,
|
||||
name_path TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Run schema migrations
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
databaseLogger.info("Checking for schema updates...", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"created_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
|
||||
// SSH credentials table migrations for encryption support
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
// Function to save in-memory database to encrypted file
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase || !enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
// Encrypt and save to file (now async)
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
||||
operation: "memory_db_save",
|
||||
bufferSize: buffer.length,
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle post-initialization file encryption and cleanup
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing unencrypted database files
|
||||
if (fs.existsSync(dbPath)) {
|
||||
databaseLogger.warn(
|
||||
"Found unencrypted database file, removing for security",
|
||||
{
|
||||
operation: "db_security_cleanup_existing",
|
||||
removingPath: dbPath,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Unencrypted database file removed for security",
|
||||
{
|
||||
operation: "db_security_cleanup_complete",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Could not remove unencrypted database file (may be locked)",
|
||||
{
|
||||
operation: "db_security_cleanup_deferred",
|
||||
path: dbPath,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
|
||||
// Try again after a short delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Delayed cleanup: unencrypted database file removed",
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_success",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (delayedError) {
|
||||
databaseLogger.error(
|
||||
"Failed to remove unencrypted database file even after delay",
|
||||
delayedError,
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_failed",
|
||||
path: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always save the in-memory database (whether new or existing)
|
||||
if (memoryDatabase) {
|
||||
// Save immediately after initialization
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
// Set up periodic saves every 5 minutes
|
||||
setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption/cleanup",
|
||||
error,
|
||||
{
|
||||
operation: "db_encrypt_cleanup_failed",
|
||||
},
|
||||
);
|
||||
|
||||
// Don't fail the entire initialization for this
|
||||
}
|
||||
}
|
||||
|
||||
// Export a promise that resolves when database is fully initialized
|
||||
export const databaseReady = initializeCompleteDatabase()
|
||||
.then(async () => {
|
||||
await handlePostInitFileEncryption();
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
hasEncryptedBackup:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database", error, {
|
||||
operation: "db_init",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Cleanup function for database and temporary files
|
||||
async function cleanupDatabase() {
|
||||
// Save in-memory database before closing
|
||||
if (memoryDatabase) {
|
||||
try {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to save in-memory database before shutdown",
|
||||
error,
|
||||
{
|
||||
operation: "shutdown_save_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
databaseLogger.debug("Database connection closed", {
|
||||
operation: "db_close",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Error closing database connection", {
|
||||
operation: "db_close_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
const tempDir = path.join(dataDir, ".temp");
|
||||
if (fs.existsSync(tempDir)) {
|
||||
const files = fs.readdirSync(tempDir);
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {
|
||||
// Ignore individual file cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
databaseLogger.debug("Temp directory cleaned up", {
|
||||
operation: "temp_dir_cleanup",
|
||||
});
|
||||
} catch {
|
||||
// Ignore directory removal errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore temp directory cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
// Register cleanup handlers
|
||||
process.on("exit", () => {
|
||||
// Synchronous cleanup only for exit event
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
databaseLogger.info("Received SIGINT, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
databaseLogger.info("Received SIGTERM, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Database connection - will be initialized after database setup
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
// Export database connection getter function to avoid undefined access
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Ensure databaseReady promise is awaited before accessing db.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// Legacy export for compatibility - will throw if accessed before initialization
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
encrypted: encryptedDbPath,
|
||||
directory: dbDir,
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
// Memory database buffer function
|
||||
function getMemoryDatabaseBuffer(): Buffer {
|
||||
if (!memoryDatabase) {
|
||||
throw new Error("Memory database not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
databaseLogger.debug("Memory database serialized to buffer", {
|
||||
operation: "memory_db_serialize",
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to serialize memory database to buffer",
|
||||
error,
|
||||
{
|
||||
operation: "memory_db_serialize_failed",
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export save function for manual saves and buffer access
|
||||
export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer };
|
||||
@@ -45,13 +45,15 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
authType: text("auth_type").notNull(),
|
||||
|
||||
password: text("password"),
|
||||
requirePassword: integer("require_password", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
key: text("key", { length: 8192 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
// AutoStart plaintext fields (populated only when autoStart is enabled)
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -171,3 +173,4 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
@@ -107,31 +108,15 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Route: Get all active alerts
|
||||
// Initialize auth middleware
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Route: Get alerts for the authenticated user (excluding dismissed ones)
|
||||
// GET /alerts
|
||||
router.get("/", async (req, res) => {
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: alerts.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||
// GET /alerts/user/:userId
|
||||
router.get("/user/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
const userId = (req as any).userId;
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
@@ -144,32 +129,33 @@ router.get("/user/:userId", async (req, res) => {
|
||||
dismissedAlertRecords.map((record) => record.alertId),
|
||||
);
|
||||
|
||||
const userAlerts = allAlerts.filter(
|
||||
const activeAlertsForUser = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: userAlerts,
|
||||
total_count: userAlerts.length,
|
||||
dismissed_count: dismissedAlertIds.size,
|
||||
alerts: activeAlertsForUser,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: activeAlertsForUser.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch user alerts" });
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for a user
|
||||
// POST /alerts/dismiss
|
||||
router.post("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
// Deprecated endpoint - use GET /alerts instead
|
||||
|
||||
if (!userId || !alertId) {
|
||||
authLogger.warn("Missing userId or alertId in dismiss request");
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
// Route: Dismiss an alert for the authenticated user
|
||||
// POST /alerts/dismiss
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
@@ -201,13 +187,9 @@ router.post("/dismiss", async (req, res) => {
|
||||
|
||||
// Route: Get dismissed alerts for a user
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get("/dismissed/:userId", async (req, res) => {
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
const userId = (req as any).userId;
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
@@ -227,16 +209,15 @@ router.get("/dismissed/:userId", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete("/dismiss", async (req, res) => {
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
|
||||
@@ -5,7 +5,8 @@ import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import {
|
||||
parseSSHKey,
|
||||
parsePublicKey,
|
||||
@@ -84,29 +85,14 @@ function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
authLogger.warn("Missing or invalid Authorization header");
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Missing or invalid Authorization header" });
|
||||
}
|
||||
const token = authHeader.split(" ")[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
authLogger.warn("Invalid or expired token");
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
// Use AuthManager middleware for authentication
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Create a new credential
|
||||
// POST /credentials
|
||||
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.post("/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {
|
||||
name,
|
||||
@@ -210,10 +196,11 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
lastUsed: null,
|
||||
};
|
||||
|
||||
const created = (await EncryptedDBOperations.insert(
|
||||
const created = (await SimpleDBOps.insert(
|
||||
sshCredentials,
|
||||
"ssh_credentials",
|
||||
credentialData,
|
||||
userId,
|
||||
)) as typeof credentialData & { id: number };
|
||||
|
||||
authLogger.success(
|
||||
@@ -245,7 +232,7 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Get all credentials for the authenticated user
|
||||
// GET /credentials
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.get("/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
@@ -254,13 +241,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await EncryptedDBOperations.select(
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.orderBy(desc(sshCredentials.updatedAt)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
||||
@@ -272,7 +260,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Get all unique credential folders for the authenticated user
|
||||
// GET /credentials/folders
|
||||
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.get("/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
@@ -305,7 +293,7 @@ router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Get a specific credential by ID (with plain text secrets)
|
||||
// GET /credentials/:id
|
||||
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.get("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -315,7 +303,7 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await EncryptedDBOperations.select(
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
@@ -326,6 +314,7 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length === 0) {
|
||||
@@ -362,7 +351,7 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Update a credential
|
||||
// PUT /credentials/:id
|
||||
router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.put("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
@@ -437,18 +426,19 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
const existing = await EncryptedDBOperations.select(
|
||||
const existing = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, parseInt(id))),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
return res.json(formatCredentialOutput(existing[0]));
|
||||
}
|
||||
|
||||
await EncryptedDBOperations.update(
|
||||
await SimpleDBOps.update(
|
||||
sshCredentials,
|
||||
"ssh_credentials",
|
||||
and(
|
||||
@@ -456,14 +446,16 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
updateFields,
|
||||
userId,
|
||||
);
|
||||
|
||||
const updated = await EncryptedDBOperations.select(
|
||||
const updated = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, parseInt(id))),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
const credential = updated[0];
|
||||
@@ -490,7 +482,7 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Delete a credential
|
||||
// DELETE /credentials/:id
|
||||
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.delete("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import multer from "multer";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../db/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -31,65 +35,198 @@ function isValidPort(port: any): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
sshLogger.warn("Missing or invalid Authorization header");
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Missing or invalid Authorization header" });
|
||||
}
|
||||
const token = authHeader.split(" ")[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
sshLogger.warn("Invalid or expired token");
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
// Use AuthManager middleware for authentication
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
function isLocalhost(req: Request) {
|
||||
const ip = req.ip || req.connection?.remoteAddress;
|
||||
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
||||
}
|
||||
|
||||
// Internal-only endpoint for autostart (no JWT)
|
||||
// Internal-only endpoint for autostart - requires internal auth token
|
||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
if (!isLocalhost(req) && req.headers["x-internal-request"] !== "1") {
|
||||
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint");
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const data = await EncryptedDBOperations.select(
|
||||
db.select().from(sshData),
|
||||
"ssh_data",
|
||||
);
|
||||
const result = data.map((row: any) => {
|
||||
// Check for internal authentication token using SystemCrypto
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint", {
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing"
|
||||
});
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to validate internal auth token", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Query sshData directly for hosts that have autostart plaintext fields populated
|
||||
const autostartHosts = await db.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
// Check if any autostart fields are populated (meaning autostart is enabled)
|
||||
or(
|
||||
isNotNull(sshData.autostartPassword),
|
||||
isNotNull(sshData.autostartKey)
|
||||
)
|
||||
);
|
||||
|
||||
console.log("=== AUTOSTART QUERY DEBUG ===");
|
||||
console.log("Found autostart hosts count:", autostartHosts.length);
|
||||
autostartHosts.forEach((host, index) => {
|
||||
console.log(`Host ${index + 1}:`, {
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0
|
||||
});
|
||||
});
|
||||
console.log("=== END AUTOSTART QUERY DEBUG ===");
|
||||
|
||||
sshLogger.info("Internal autostart endpoint accessed", {
|
||||
operation: "autostart_internal_access",
|
||||
configCount: autostartHosts.length,
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"]
|
||||
});
|
||||
|
||||
// Transform to expected format for tunnel service
|
||||
const result = autostartHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
// Debug: Log what we're reading from database
|
||||
sshLogger.info(`Autostart host from DB:`, {
|
||||
hostId: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0,
|
||||
});
|
||||
|
||||
return {
|
||||
...row,
|
||||
tags:
|
||||
typeof row.tags === "string"
|
||||
? row.tags
|
||||
? row.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
requirePassword: !!row.requirePassword,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
? JSON.parse(row.tunnelConnections)
|
||||
: [],
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `autostart-${host.id}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.autostartPassword,
|
||||
key: host.autostartKey,
|
||||
keyPassword: host.autostartKeyPassword,
|
||||
// Include explicit autostart fields for tunnel service
|
||||
autostartPassword: host.autostartPassword,
|
||||
autostartKey: host.autostartKey,
|
||||
autostartKeyPassword: host.autostartKeyPassword,
|
||||
authType: host.authType,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
|
||||
pin: false,
|
||||
enableTerminal: false,
|
||||
enableFileManager: false,
|
||||
tags: ["autostart"],
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH data (internal)", err);
|
||||
res.status(500).json({ error: "Failed to fetch SSH data" });
|
||||
sshLogger.error("Failed to fetch autostart SSH data", err);
|
||||
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
|
||||
}
|
||||
});
|
||||
|
||||
// Internal-only endpoint for all hosts - requires internal auth token (for tunnel endpointHost resolution)
|
||||
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check for internal authentication token using SystemCrypto
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
if (!internalToken) {
|
||||
return res.status(401).json({ error: "Internal authentication token required" });
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
return res.status(401).json({ error: "Invalid internal authentication token" });
|
||||
}
|
||||
|
||||
// Query all hosts for endpointHost resolution
|
||||
const allHosts = await db.select().from(sshData);
|
||||
|
||||
sshLogger.info("Internal all hosts endpoint accessed", {
|
||||
operation: "all_hosts_internal_access",
|
||||
hostCount: allHosts.length,
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"]
|
||||
});
|
||||
|
||||
// Transform to expected format for tunnel service
|
||||
const result = allHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
// Debug: Log what we're reading from database for all hosts
|
||||
sshLogger.info(`All hosts endpoint - host from DB:`, {
|
||||
hostId: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0,
|
||||
encryptedPasswordLength: host.password?.length || 0,
|
||||
encryptedKeyLength: host.key?.length || 0,
|
||||
});
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `${host.username}@${host.ip}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.autostartPassword || host.password,
|
||||
key: host.autostartKey || host.key,
|
||||
keyPassword: host.autostartKeyPassword || host.keyPassword,
|
||||
// Include autostart fields for fallback
|
||||
autostartPassword: host.autostartPassword,
|
||||
autostartKey: host.autostartKey,
|
||||
autostartKeyPassword: host.autostartKeyPassword,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: tunnelConnections,
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch all hosts for internal use", err);
|
||||
res.status(500).json({ error: "Failed to fetch all hosts" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,6 +235,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
router.post(
|
||||
"/db/host",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
@@ -138,7 +276,6 @@ router.post(
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
requirePassword,
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
@@ -190,7 +327,6 @@ router.post(
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
sshDataObj.password = password || null;
|
||||
sshDataObj.requirePassword = requirePassword !== false ? 1 : 0;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
@@ -199,21 +335,20 @@ router.post(
|
||||
sshDataObj.keyPassword = keyPassword || null;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||
} else {
|
||||
// For credential auth
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await EncryptedDBOperations.insert(
|
||||
const result = await SimpleDBOps.insert(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
sshDataObj,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
@@ -237,7 +372,6 @@ router.post(
|
||||
: []
|
||||
: [],
|
||||
pin: !!createdHost.pin,
|
||||
requirePassword: !!createdHost.requirePassword,
|
||||
enableTerminal: !!createdHost.enableTerminal,
|
||||
enableTunnel: !!createdHost.enableTunnel,
|
||||
tunnelConnections: createdHost.tunnelConnections
|
||||
@@ -324,7 +458,6 @@ router.put(
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
requirePassword,
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
@@ -379,7 +512,6 @@ router.put(
|
||||
if (password) {
|
||||
sshDataObj.password = password;
|
||||
}
|
||||
sshDataObj.requirePassword = requirePassword !== false ? 1 : 0;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
@@ -394,25 +526,24 @@ router.put(
|
||||
sshDataObj.keyType = keyType;
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||
} else {
|
||||
// For credential auth
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.requirePassword = 1; // Default to true for non-password auth
|
||||
}
|
||||
|
||||
try {
|
||||
await EncryptedDBOperations.update(
|
||||
await SimpleDBOps.update(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||
sshDataObj,
|
||||
userId,
|
||||
);
|
||||
|
||||
const updatedHosts = await EncryptedDBOperations.select(
|
||||
const updatedHosts = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -420,6 +551,7 @@ router.put(
|
||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||
),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (updatedHosts.length === 0) {
|
||||
@@ -441,7 +573,6 @@ router.put(
|
||||
: []
|
||||
: [],
|
||||
pin: !!updatedHost.pin,
|
||||
requirePassword: !!updatedHost.requirePassword,
|
||||
enableTerminal: !!updatedHost.enableTerminal,
|
||||
enableTunnel: !!updatedHost.enableTunnel,
|
||||
tunnelConnections: updatedHost.tunnelConnections
|
||||
@@ -493,9 +624,10 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const data = await EncryptedDBOperations.select(
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const result = await Promise.all(
|
||||
@@ -509,7 +641,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
: []
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
requirePassword: !!row.requirePassword,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
@@ -1113,7 +1244,7 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedHosts = await EncryptedDBOperations.update(
|
||||
const updatedHosts = await SimpleDBOps.update(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
|
||||
@@ -1121,6 +1252,7 @@ router.put(
|
||||
folder: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const updatedCredentials = await db
|
||||
@@ -1137,6 +1269,9 @@ router.put(
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Trigger database save after folder rename
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
updatedHosts: updatedHosts.length,
|
||||
@@ -1261,7 +1396,7 @@ router.post(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await EncryptedDBOperations.insert(sshData, "ssh_data", sshDataObj);
|
||||
await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
@@ -1280,4 +1415,295 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Enable autostart for SSH configuration (requires JWT)
|
||||
// POST /ssh/autostart/enable
|
||||
router.post(
|
||||
"/autostart/enable",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn("Missing or invalid sshConfigId in autostart enable request", {
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate user has access to decrypt the data
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
sshLogger.warn("User attempted to enable autostart without unlocked data", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked"
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked."
|
||||
});
|
||||
}
|
||||
|
||||
// Get and decrypt SSH configuration
|
||||
const sshConfig = await db.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.id, sshConfigId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
if (sshConfig.length === 0) {
|
||||
sshLogger.warn("SSH config not found for autostart enable", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "config_not_found"
|
||||
});
|
||||
return res.status(404).json({
|
||||
error: "SSH configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
const config = sshConfig[0];
|
||||
|
||||
// Decrypt sensitive fields
|
||||
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
||||
|
||||
// Debug: Log what we're about to save
|
||||
console.log("=== AUTOSTART DEBUG: Decrypted credentials ===");
|
||||
console.log("sshConfigId:", sshConfigId);
|
||||
console.log("authType:", config.authType);
|
||||
console.log("hasPassword:", !!decryptedConfig.password);
|
||||
console.log("hasKey:", !!decryptedConfig.key);
|
||||
console.log("hasKeyPassword:", !!decryptedConfig.keyPassword);
|
||||
console.log("passwordLength:", decryptedConfig.password?.length || 0);
|
||||
console.log("keyLength:", decryptedConfig.key?.length || 0);
|
||||
console.log("=== END AUTOSTART DEBUG ===");
|
||||
|
||||
// Also handle tunnel connections - populate endpoint credentials
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
if (config.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||
|
||||
// For each tunnel connection, try to resolve endpoint credentials
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: any) => {
|
||||
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
||||
console.log("=== RESOLVING ENDPOINT CREDENTIALS ===");
|
||||
console.log("endpointHost:", tunnel.endpointHost);
|
||||
|
||||
// Find endpoint host by name or username@ip
|
||||
const endpointHosts = await db.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
|
||||
const endpointHost = endpointHosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
console.log("Found endpoint host:", endpointHost.id, endpointHost.ip);
|
||||
|
||||
// Decrypt endpoint host credentials
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
||||
|
||||
console.log("Endpoint credentials:", {
|
||||
hasPassword: !!decryptedEndpoint.password,
|
||||
hasKey: !!decryptedEndpoint.key,
|
||||
passwordLength: decryptedEndpoint.password?.length || 0
|
||||
});
|
||||
|
||||
// Add endpoint credentials to tunnel connection
|
||||
return {
|
||||
...tunnel,
|
||||
endpointPassword: decryptedEndpoint.password || null,
|
||||
endpointKey: decryptedEndpoint.key || null,
|
||||
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
|
||||
endpointAuthType: endpointHost.authType
|
||||
};
|
||||
}
|
||||
}
|
||||
return tunnel;
|
||||
})
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
console.log("=== UPDATED TUNNEL CONNECTIONS ===");
|
||||
} catch (error) {
|
||||
console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the SSH config with plaintext autostart fields and resolved tunnel connections
|
||||
const updateResult = await db.update(sshData)
|
||||
.set({
|
||||
autostartPassword: decryptedConfig.password || null,
|
||||
autostartKey: decryptedConfig.key || null,
|
||||
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
||||
tunnelConnections: updatedTunnelConnections,
|
||||
})
|
||||
.where(eq(sshData.id, sshConfigId));
|
||||
|
||||
// Debug: Log update result
|
||||
console.log("=== AUTOSTART DEBUG: Update result ===");
|
||||
console.log("updateResult:", updateResult);
|
||||
console.log("update completed for sshConfigId:", sshConfigId);
|
||||
console.log("=== END UPDATE DEBUG ===");
|
||||
|
||||
// Force database save after autostart update
|
||||
try {
|
||||
await DatabaseSaveTrigger.triggerSave();
|
||||
console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ===");
|
||||
} catch (saveError) {
|
||||
console.log("=== DATABASE SAVE FAILED ===", saveError);
|
||||
}
|
||||
|
||||
// Verify the data was actually saved
|
||||
try {
|
||||
const verifyQuery = await db.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, sshConfigId));
|
||||
|
||||
if (verifyQuery.length > 0) {
|
||||
const saved = verifyQuery[0];
|
||||
console.log("=== VERIFICATION: Data actually saved ===");
|
||||
console.log("autostartPassword exists:", !!saved.autostartPassword);
|
||||
console.log("autostartKey exists:", !!saved.autostartKey);
|
||||
console.log("autostartPassword length:", saved.autostartPassword?.length || 0);
|
||||
console.log("=== END VERIFICATION ===");
|
||||
}
|
||||
} catch (verifyError) {
|
||||
console.log("=== VERIFICATION FAILED ===", verifyError);
|
||||
}
|
||||
|
||||
sshLogger.success("AutoStart enabled successfully", {
|
||||
operation: "autostart_enabled",
|
||||
userId,
|
||||
sshConfigId,
|
||||
host: config.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart enabled successfully",
|
||||
sshConfigId
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error enabling autostart", error, {
|
||||
operation: "autostart_enable_error",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Route: Disable autostart for SSH configuration (requires JWT)
|
||||
// DELETE /ssh/autostart/disable
|
||||
router.delete(
|
||||
"/autostart/disable",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn("Missing or invalid sshConfigId in autostart disable request", {
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear the autostart plaintext fields for this SSH config
|
||||
const result = await db.update(sshData)
|
||||
.set({
|
||||
autostartPassword: null,
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
})
|
||||
.where(and(
|
||||
eq(sshData.id, sshConfigId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
sshLogger.info("AutoStart disabled successfully", {
|
||||
operation: "autostart_disabled",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart disabled successfully",
|
||||
sshConfigId
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error disabling autostart", error, {
|
||||
operation: "autostart_disable_error",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Route: Get autostart status for user's SSH configurations (requires JWT)
|
||||
// GET /ssh/autostart/status
|
||||
router.get(
|
||||
"/autostart/status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
// Query user's SSH configs that have autostart enabled
|
||||
const autostartConfigs = await db.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.userId, userId),
|
||||
or(
|
||||
isNotNull(sshData.autostartPassword),
|
||||
isNotNull(sshData.autostartKey)
|
||||
)
|
||||
));
|
||||
|
||||
// Map to just the basic info needed for status
|
||||
const statusList = autostartConfigs.map(config => ({
|
||||
sshConfigId: config.id,
|
||||
host: config.ip,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authType: config.authType
|
||||
}));
|
||||
|
||||
sshLogger.info("AutoStart status retrieved", {
|
||||
operation: "autostart_status",
|
||||
userId,
|
||||
configCount: statusList.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
autostart_configs: statusList,
|
||||
total_count: statusList.length
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error getting autostart status", error, {
|
||||
operation: "autostart_status_error",
|
||||
userId
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
settings,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -16,6 +17,12 @@ import speakeasy from "speakeasy";
|
||||
import QRCode from "qrcode";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { authLogger, apiLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
// Get auth manager instance
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
@@ -129,35 +136,12 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
authLogger.warn("Missing or invalid Authorization header", {
|
||||
operation: "auth",
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
});
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Missing or invalid Authorization header" });
|
||||
}
|
||||
const token = authHeader.split(" ")[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
authLogger.warn("Invalid or expired token", {
|
||||
operation: "auth",
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
error: err,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
// JWT authentication middleware - only verify JWT, no data unlock required
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
// Data access middleware - requires user to have unlocked data keys
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Route: Create traditional user (username/password)
|
||||
// POST /users/create
|
||||
@@ -208,19 +192,10 @@ router.post("/create", async (req, res) => {
|
||||
}
|
||||
|
||||
let isFirstUser = false;
|
||||
try {
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||
} catch (e) {
|
||||
isFirstUser = true;
|
||||
authLogger.warn("Failed to check user count, assuming first user", {
|
||||
operation: "user_create",
|
||||
username,
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
@@ -244,6 +219,25 @@ router.post("/create", async (req, res) => {
|
||||
totp_backup_codes: null,
|
||||
});
|
||||
|
||||
// Set up user data encryption (KEK-DEK architecture)
|
||||
try {
|
||||
await authManager.registerUser(id, password);
|
||||
authLogger.success("User encryption setup completed", {
|
||||
operation: "user_encryption_setup",
|
||||
userId: id,
|
||||
});
|
||||
} catch (encryptionError) {
|
||||
// If encryption setup fails, delete user record
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
authLogger.error("Failed to setup user encryption, user creation rolled back", encryptionError, {
|
||||
operation: "user_create_encryption_failed",
|
||||
userId: id,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to setup user security - user creation cancelled"
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
|
||||
{
|
||||
@@ -343,11 +337,46 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
scopes: scopes || "openid email profile",
|
||||
};
|
||||
|
||||
// Encrypt sensitive configuration for storage
|
||||
let encryptedConfig;
|
||||
try {
|
||||
// Use admin's data key to encrypt OIDC configuration
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
// Provide stable recordId for settings objects
|
||||
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
||||
encryptedConfig = DataCrypto.encryptRecord("settings", configWithId, userId, adminDataKey);
|
||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
||||
operation: "oidc_config_encrypt",
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
// If admin data not unlocked, only encrypt client_secret
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString('base64')}`, // Simple base64 encoding
|
||||
};
|
||||
authLogger.warn("OIDC configuration stored with basic encoding - admin should re-save with password", {
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} catch (encryptError) {
|
||||
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, {
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
});
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)",
|
||||
)
|
||||
.run(JSON.stringify(config));
|
||||
.run(JSON.stringify(encryptedConfig));
|
||||
authLogger.info("OIDC configuration updated", {
|
||||
operation: "oidc_update",
|
||||
userId,
|
||||
@@ -383,7 +412,7 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get OIDC configuration
|
||||
// Route: Get OIDC configuration (public - needed for login page)
|
||||
// GET /users/oidc-config
|
||||
router.get("/oidc-config", async (req, res) => {
|
||||
try {
|
||||
@@ -393,7 +422,62 @@ router.get("/oidc-config", async (req, res) => {
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
}
|
||||
res.json(JSON.parse((row as any).value));
|
||||
|
||||
let config = JSON.parse((row as any).value);
|
||||
|
||||
// Decrypt or decode client_secret for display
|
||||
if (config.client_secret) {
|
||||
if (config.client_secret.startsWith('encrypted:')) {
|
||||
// Requires admin permission to decrypt
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
const authManager = AuthManager.getInstance();
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (payload) {
|
||||
const userId = payload.userId;
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
|
||||
if (user && user.length > 0 && user[0].is_admin) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
// Use same stable recordId for decryption - note: FieldCrypto will use stored recordId
|
||||
config = DataCrypto.decryptRecord("settings", config, userId, adminDataKey);
|
||||
} else {
|
||||
// Admin data not unlocked, hide client_secret
|
||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||
}
|
||||
} catch (decryptError) {
|
||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||
operation: "oidc_config_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else if (config.client_secret.startsWith('encoded:')) {
|
||||
// base64 decode
|
||||
try {
|
||||
const decoded = Buffer.from(config.client_secret.substring(8), 'base64').toString('utf8');
|
||||
config.client_secret = decoded;
|
||||
} catch {
|
||||
config.client_secret = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
// Otherwise plaintext, return directly
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config" });
|
||||
@@ -654,14 +738,10 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
let isFirstUser = false;
|
||||
if (!user || user.length === 0) {
|
||||
try {
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||
} catch (e) {
|
||||
isFirstUser = true;
|
||||
}
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({
|
||||
@@ -693,8 +773,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
expiresIn: "50d",
|
||||
});
|
||||
|
||||
@@ -775,22 +854,69 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
|
||||
expiresIn: "50d",
|
||||
});
|
||||
|
||||
// Check if legacy user needs encryption setup
|
||||
try {
|
||||
const kekSalt = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
|
||||
|
||||
if (kekSalt.length === 0) {
|
||||
// Legacy user first login - set up new encryption
|
||||
await authManager.registerUser(userRecord.id, password);
|
||||
authLogger.success("Legacy user encryption initialized", {
|
||||
operation: "legacy_user_setup",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
});
|
||||
}
|
||||
} catch (setupError) {
|
||||
authLogger.error("Failed to initialize user encryption", setupError, {
|
||||
operation: "user_encryption_setup_failed",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
});
|
||||
// Encryption setup failure should not block login for existing users
|
||||
}
|
||||
|
||||
// Unlock user data keys
|
||||
const dataUnlocked = await authManager.authenticateUser(userRecord.id, password);
|
||||
if (!dataUnlocked) {
|
||||
authLogger.error("Failed to unlock user data during login", undefined, {
|
||||
operation: "user_login_data_unlock_failed",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to unlock user data - please contact administrator"
|
||||
});
|
||||
}
|
||||
|
||||
// TOTP handling
|
||||
if (userRecord.totp_enabled) {
|
||||
const tempToken = jwt.sign(
|
||||
{ userId: userRecord.id, pending_totp: true },
|
||||
jwtSecret,
|
||||
{ expiresIn: "10m" },
|
||||
);
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
expiresIn: "10m",
|
||||
});
|
||||
return res.json({
|
||||
requires_totp: true,
|
||||
temp_token: tempToken,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate normal JWT token
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
expiresIn: "24h",
|
||||
});
|
||||
|
||||
authLogger.success(`User logged in successfully: ${username}`, {
|
||||
operation: "user_login_success",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
dataUnlocked: true,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
is_admin: !!userRecord.is_admin,
|
||||
@@ -829,10 +955,36 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Count users
|
||||
// GET /users/count
|
||||
router.get("/count", async (req, res) => {
|
||||
// Route: Check if system requires initial setup (public - for first-time setup detection)
|
||||
// GET /users/setup-required
|
||||
router.get("/setup-required", async (req, res) => {
|
||||
try {
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
const count = (countResult as any)?.count || 0;
|
||||
|
||||
res.json({
|
||||
setup_required: count === 0,
|
||||
// 不暴露具体用户数量,只返回是否需要初始化
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check setup status", err);
|
||||
res.status(500).json({ error: "Failed to check setup status" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Count users (admin only - for dashboard statistics)
|
||||
// GET /users/count
|
||||
router.get("/count", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
// 只有管理员可以查看用户统计
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user[0] || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const countResult = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get();
|
||||
@@ -846,7 +998,7 @@ router.get("/count", async (req, res) => {
|
||||
|
||||
// Route: DB health check (actually queries DB)
|
||||
// GET /users/db-health
|
||||
router.get("/db-health", async (req, res) => {
|
||||
router.get("/db-health", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
db.$client.prepare("SELECT 1").get();
|
||||
res.json({ status: "ok" });
|
||||
@@ -856,7 +1008,7 @@ router.get("/db-health", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get registration allowed status
|
||||
// Route: Get registration allowed status (public - needed for login page)
|
||||
// GET /users/registration-allowed
|
||||
router.get("/registration-allowed", async (req, res) => {
|
||||
try {
|
||||
@@ -1245,11 +1397,9 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
return res.status(400).json({ error: "Token and TOTP code are required" });
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(temp_token, jwtSecret) as any;
|
||||
if (!decoded.pending_totp) {
|
||||
const decoded = await authManager.verifyJWTToken(temp_token);
|
||||
if (!decoded || !decoded.pendingTOTP) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
@@ -1291,7 +1441,7 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
.where(eq(users.id, userRecord.id));
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
expiresIn: "50d",
|
||||
});
|
||||
|
||||
@@ -1606,4 +1756,175 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== New security API endpoints =====
|
||||
|
||||
// Route: User data unlock - used when session expires
|
||||
// POST /users/unlock-data
|
||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Password is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (unlocked) {
|
||||
authLogger.success("User data unlocked", {
|
||||
operation: "user_data_unlock",
|
||||
userId,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data unlocked successfully"
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Failed to unlock user data - invalid password", {
|
||||
operation: "user_data_unlock_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Data unlock failed", err, {
|
||||
operation: "user_data_unlock_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unlock data" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Check user data unlock status
|
||||
// GET /users/data-status
|
||||
router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
const isUnlocked = authManager.isUserUnlocked(userId);
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
const sessionStatus = { unlocked: isUnlocked };
|
||||
|
||||
res.json({
|
||||
isUnlocked,
|
||||
session: sessionStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get data status", err, {
|
||||
operation: "data_status_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get data status" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: User logout (clear data session)
|
||||
// POST /users/logout
|
||||
router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
authManager.logoutUser(userId);
|
||||
authLogger.info("User logged out", {
|
||||
operation: "user_logout",
|
||||
userId,
|
||||
});
|
||||
res.json({ message: "Logged out successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Logout failed", err, {
|
||||
operation: "logout_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Logout failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Change user password (re-encrypt data keys)
|
||||
// POST /users/change-password
|
||||
router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
error: "Current password and new password are required"
|
||||
});
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
error: "New password must be at least 8 characters long"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify current password and change
|
||||
const success = await authManager.changeUserPassword(
|
||||
userId,
|
||||
currentPassword,
|
||||
newPassword
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Also update password hash in database
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ password_hash: newPasswordHash })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
authLogger.success("User password changed successfully", {
|
||||
operation: "password_change_success",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Password changed successfully"
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Password change failed - invalid current password", {
|
||||
operation: "password_change_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(401).json({ error: "Current password is incorrect" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Password change failed", err, {
|
||||
operation: "password_change_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to change password" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get security status (admin)
|
||||
// GET /users/security-status
|
||||
router.get("/security-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
// Simplified security status for new architecture
|
||||
const securityStatus = {
|
||||
initialized: true,
|
||||
system: { hasSecret: true, isValid: true },
|
||||
activeSessions: {},
|
||||
activeSessionCount: 0
|
||||
};
|
||||
res.json(securityStatus);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get security status", err, {
|
||||
operation: "security_status_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get security status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
// 可执行文件检测工具函数
|
||||
// Executable file detection utility function
|
||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
// 检查执行权限位 (user, group, other)
|
||||
// Check execute permission bits (user, group, other)
|
||||
const hasExecutePermission =
|
||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||
|
||||
// 常见的脚本文件扩展名
|
||||
// Common script file extensions
|
||||
const scriptExtensions = [
|
||||
".sh",
|
||||
".py",
|
||||
@@ -29,13 +30,13 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
// 常见的编译可执行文件(无扩展名或特定扩展名)
|
||||
// Common compiled executable files (no extension or specific extensions)
|
||||
const executableExtensions = [".bin", ".exe", ".out"];
|
||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
// 无扩展名且有执行权限的文件通常是可执行文件
|
||||
// Files with no extension and execute permission are usually executable files
|
||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||
|
||||
return (
|
||||
@@ -58,9 +59,13 @@ app.use(
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use(express.raw({ limit: "200mb", type: "application/octet-stream" }));
|
||||
app.use(express.json({ limit: "1gb" }));
|
||||
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
||||
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||
|
||||
// Initialize AuthManager and add authentication middleware
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
@@ -85,7 +90,14 @@ function cleanupSession(sessionId: string) {
|
||||
function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
// Clear existing timeout
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
// Increase timeout to 30 minutes of inactivity
|
||||
session.timeout = setTimeout(() => {
|
||||
fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`);
|
||||
cleanupSession(sessionId);
|
||||
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +113,19 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
keyPassword,
|
||||
authType,
|
||||
credentialId,
|
||||
userId,
|
||||
} = req.body;
|
||||
|
||||
// Use authenticated user ID from middleware
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
fileLogger.error("SSH connection rejected: no authenticated user", {
|
||||
operation: "file_connect_auth",
|
||||
sessionId,
|
||||
});
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
fileLogger.warn("Missing SSH connection parameters for file manager", {
|
||||
operation: "file_connect",
|
||||
@@ -123,8 +145,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||
if (credentialId && hostId && userId) {
|
||||
try {
|
||||
const credentials = await EncryptedDBOperations.select(
|
||||
db
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
@@ -134,6 +156,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -176,9 +199,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 0,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 0,
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
@@ -201,7 +224,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
@@ -259,6 +282,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
});
|
||||
|
||||
@@ -297,6 +321,41 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||||
res.json({ status: "success", connected: isConnected });
|
||||
});
|
||||
|
||||
// SSH keepalive endpoint - extends session timeout and verifies connection
|
||||
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
const session = sshSessions[sessionId];
|
||||
|
||||
if (!session || !session.isConnected) {
|
||||
return res.status(400).json({
|
||||
error: "SSH session not found or not connected",
|
||||
connected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update last active time and reschedule cleanup
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
fileLogger.debug(`SSH session keepalive: ${sessionId}`, {
|
||||
operation: "ssh_keepalive",
|
||||
sessionId,
|
||||
lastActive: session.lastActive,
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: "success",
|
||||
connected: true,
|
||||
message: "Session keepalive successful",
|
||||
lastActive: session.lastActive
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -351,12 +410,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
// 日期可能占夨3个部分(月 日 时间)或者是(月 日 年)
|
||||
// Date may occupy 3 parts (month day time) or (month day year)
|
||||
let dateStr = "";
|
||||
let nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
// 常规格式: 月 日 时间/年
|
||||
// Regular format: month day time/year
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
@@ -366,7 +425,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
// 解析符号链接目标
|
||||
// Parse symbolic link target
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
@@ -378,17 +437,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size, // 目录不显示大小
|
||||
size: isDirectory ? undefined : size, // Directories don't show size
|
||||
modified: dateStr,
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget, // 符号链接的目标
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // 添加完整路径
|
||||
linkTarget, // Symbolic link target
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // Add full path
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false, // 检测可执行文件
|
||||
: false, // Detect executable files
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -484,8 +543,8 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
// First check file size to prevent loading huge files
|
||||
const MAX_READ_SIZE = 10 * 1024 * 1024; // 10MB - same as frontend limit
|
||||
// Support large file reading - increased limit for better compatibility
|
||||
const MAX_READ_SIZE = 500 * 1024 * 1024; // 500MB - much more reasonable limit
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Get file size first
|
||||
@@ -510,10 +569,20 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
|
||||
sizeStream.on("close", (sizeCode) => {
|
||||
if (sizeCode !== 0) {
|
||||
// Check if it's a file not found error (case-insensitive)
|
||||
const errorLower = sizeErrorData.toLowerCase();
|
||||
const isFileNotFound = errorLower.includes("no such file or directory") ||
|
||||
errorLower.includes("cannot access") ||
|
||||
errorLower.includes("not found") ||
|
||||
errorLower.includes("resource not found");
|
||||
|
||||
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Cannot check file size: ${sizeErrorData}` });
|
||||
.status(isFileNotFound ? 404 : 500)
|
||||
.json({
|
||||
error: `Cannot check file size: ${sizeErrorData}`,
|
||||
fileNotFound: isFileNotFound
|
||||
});
|
||||
}
|
||||
|
||||
const fileSize = parseInt(sizeData.trim(), 10);
|
||||
@@ -563,9 +632,19 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
fileLogger.error(
|
||||
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
|
||||
// Check if it's a "file not found" error
|
||||
const isFileNotFound =
|
||||
errorData.includes("No such file or directory") ||
|
||||
errorData.includes("cannot access") ||
|
||||
errorData.includes("not found");
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Command failed: ${errorData}` });
|
||||
.status(isFileNotFound ? 404 : 500)
|
||||
.json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
fileNotFound: isFileNotFound
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ content: data, path: filePath });
|
||||
@@ -1492,8 +1571,22 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
|
||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
// Add timeout for move operation
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
error: "Move operation timed out. SSH connection may be unstable.",
|
||||
toast: {
|
||||
type: "error",
|
||||
message: "Move operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000); // 60 second timeout for move operations
|
||||
|
||||
sshConn.client.exec(moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
@@ -1527,6 +1620,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
@@ -1569,6 +1663,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
@@ -1633,8 +1728,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
.json({ error: "Cannot download directories or special files" });
|
||||
}
|
||||
|
||||
// Check file size (limit to 100MB for safety)
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
// Support large file downloads - increased limit for better compatibility
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - reasonable for SSH file operations
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
fileLogger.warn("File too large for download", {
|
||||
operation: "file_download",
|
||||
@@ -1705,66 +1800,26 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
// Extract source name
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
// First check if source file exists
|
||||
const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const checkExistsCommand = `test -e '${escapedSourceForCheck}'`;
|
||||
const checkExists = await new Promise<boolean>((resolve) => {
|
||||
sshConn.client.exec(checkExistsCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("File existence check error:", err);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on("close", (code) => {
|
||||
fileLogger.info("File existence check completed", {
|
||||
sourcePath,
|
||||
exists: code === 0,
|
||||
});
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
stream.on("error", () => resolve(false));
|
||||
});
|
||||
});
|
||||
|
||||
if (!checkExists) {
|
||||
return res.status(404).json({
|
||||
error: `Source file not found: ${sourcePath}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Source file not found: ${sourceName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Use timestamp for uniqueness
|
||||
// Linus principle: simplify - generate unique name directly without complex checks
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const nameWithoutExt = sourceName.includes(".")
|
||||
? sourceName.substring(0, sourceName.lastIndexOf("."))
|
||||
: sourceName;
|
||||
const extension = sourceName.includes(".")
|
||||
? sourceName.substring(sourceName.lastIndexOf("."))
|
||||
: "";
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
// Always use timestamp suffix to ensure uniqueness without SSH calls
|
||||
const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`;
|
||||
|
||||
fileLogger.info("Using timestamp-based unique name", {
|
||||
fileLogger.info("Starting copy operation", {
|
||||
originalName: sourceName,
|
||||
uniqueName,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sessionId,
|
||||
});
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
// Escape paths for shell commands
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Use cp with explicit flags to avoid hanging on prompts
|
||||
// -f: force overwrite without prompting
|
||||
// -r: recursive for directories
|
||||
// -p: preserve timestamps, permissions
|
||||
const copyCommand = `cp -fpr '${escapedSource}' '${escapedTarget}' 2>&1`;
|
||||
// Linus principle: simplify - use basic cp command for reliability
|
||||
// Just copy the file without complex flags that might cause issues
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
fileLogger.info("Starting file copy operation", {
|
||||
operation: "file_copy_start",
|
||||
@@ -1777,7 +1832,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
const commandTimeout = setTimeout(() => {
|
||||
fileLogger.error("Copy command timed out after 20 seconds", {
|
||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
@@ -1792,7 +1847,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 20000); // 20 second timeout for better responsiveness
|
||||
}, 60000); // 60 second timeout for large files
|
||||
|
||||
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
@@ -1864,27 +1919,54 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("Item copied successfully", {
|
||||
operation: "file_copy",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
// Verify copy completion with COPY_SUCCESS marker or exit code 0
|
||||
const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item copied successfully",
|
||||
if (copySuccessful) {
|
||||
fileLogger.success("Item copied successfully", {
|
||||
operation: "file_copy",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Successfully copied to: ${uniqueName}`,
|
||||
},
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item copied successfully",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Successfully copied to: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fileLogger.warn("Copy completed but without success confirmation", {
|
||||
operation: "file_copy_uncertain",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
code,
|
||||
stdoutData: stdoutData.substring(0, 200),
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Copy may have completed",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "warning",
|
||||
message: `Copy completed but verification uncertain for: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1933,7 +2015,7 @@ process.on("SIGTERM", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 执行可执行文件
|
||||
// Execute executable file
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath, hostId, userId } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -1957,7 +2039,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// 检查文件是否存在且可执行
|
||||
// Check if file exists and is executable
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
@@ -1978,7 +2060,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
// 执行文件
|
||||
// Execute file
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
fileLogger.info("Executing file", {
|
||||
@@ -2006,7 +2088,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
// 从输出中提取退出代码
|
||||
// Extract exit code from output
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
@@ -2043,9 +2125,21 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
});
|
||||
|
||||
const PORT = 8084;
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
fileLogger.success("File Manager API server started", {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
// Initialize AuthManager for JWT verification
|
||||
try {
|
||||
await authManager.initialize();
|
||||
fileLogger.info("AuthManager initialized for file manager", {
|
||||
operation: "auth_init",
|
||||
});
|
||||
} catch (err) {
|
||||
fileLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,11 +2,12 @@ import express from "express";
|
||||
import net from "net";
|
||||
import cors from "cors";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
interface PooledConnection {
|
||||
client: Client;
|
||||
@@ -228,6 +229,7 @@ class MetricsCache {
|
||||
const connectionPool = new SSHConnectionPool();
|
||||
const requestQueue = new RequestQueue();
|
||||
const metricsCache = new MetricsCache();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
type HostStatus = "online" | "offline";
|
||||
|
||||
@@ -303,19 +305,23 @@ app.use((req, res, next) => {
|
||||
});
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
// Add authentication middleware - Linus principle: eliminate special cases
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||
async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
|
||||
try {
|
||||
const hosts = await EncryptedDBOperations.select(
|
||||
db.select().from(sshData),
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
const hostWithCreds = await resolveHostCredentials(host);
|
||||
const hostWithCreds = await resolveHostCredentials(host, userId);
|
||||
if (hostWithCreds) {
|
||||
hostsWithCredentials.push(hostWithCreds);
|
||||
}
|
||||
@@ -335,11 +341,13 @@ async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||
|
||||
async function fetchHostById(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
const hosts = await EncryptedDBOperations.select(
|
||||
db.select().from(sshData).where(eq(sshData.id, id)),
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
@@ -347,7 +355,7 @@ async function fetchHostById(
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
return await resolveHostCredentials(host);
|
||||
return await resolveHostCredentials(host, userId);
|
||||
} catch (err) {
|
||||
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||
return undefined;
|
||||
@@ -356,6 +364,7 @@ async function fetchHostById(
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: any,
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
const baseHost: any = {
|
||||
@@ -387,17 +396,18 @@ async function resolveHostCredentials(
|
||||
|
||||
if (host.credentialId) {
|
||||
try {
|
||||
const credentials = await EncryptedDBOperations.select(
|
||||
db
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -480,7 +490,31 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
port: host.port || 22,
|
||||
username: host.username || "root",
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
} as ConnectConfig;
|
||||
|
||||
if (host.authType === "password") {
|
||||
@@ -809,11 +843,19 @@ function tcpPing(
|
||||
});
|
||||
}
|
||||
|
||||
async function pollStatusesOnce(): Promise<void> {
|
||||
const hosts = await fetchAllHosts();
|
||||
async function pollStatusesOnce(userId?: string): Promise<void> {
|
||||
if (!userId) {
|
||||
statsLogger.warn("Skipping status poll - no authenticated user", {
|
||||
operation: "status_poll",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts = await fetchAllHosts(userId);
|
||||
if (hosts.length === 0) {
|
||||
statsLogger.warn("No hosts retrieved for status polling", {
|
||||
operation: "status_poll",
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -845,8 +887,10 @@ async function pollStatusesOnce(): Promise<void> {
|
||||
}
|
||||
|
||||
app.get("/status", async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (hostStatuses.size === 0) {
|
||||
await pollStatusesOnce();
|
||||
await pollStatusesOnce(userId);
|
||||
}
|
||||
const result: Record<number, StatusEntry> = {};
|
||||
for (const [id, entry] of hostStatuses.entries()) {
|
||||
@@ -857,9 +901,10 @@ app.get("/status", async (req, res) => {
|
||||
|
||||
app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
const host = await fetchHostById(id, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
@@ -880,15 +925,17 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/refresh", async (req, res) => {
|
||||
await pollStatusesOnce();
|
||||
const userId = (req as any).userId;
|
||||
await pollStatusesOnce(userId);
|
||||
res.json({ message: "Refreshed" });
|
||||
});
|
||||
|
||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
const host = await fetchHostById(id, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
@@ -947,11 +994,21 @@ app.listen(PORT, async () => {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
// Initialize AuthManager for JWT verification
|
||||
try {
|
||||
await pollStatusesOnce();
|
||||
await authManager.initialize();
|
||||
statsLogger.info("AuthManager initialized for metrics collection", {
|
||||
operation: "auth_init",
|
||||
});
|
||||
} catch (err) {
|
||||
statsLogger.error("Initial poll failed", err, {
|
||||
operation: "initial_poll",
|
||||
statsLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
|
||||
// Skip initial poll - requires user authentication
|
||||
statsLogger.info("Server ready - status polling will begin with first authenticated request", {
|
||||
operation: "server_ready",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,220 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
// Get auth instances
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||
operation: "server_start",
|
||||
// Track user connections for rate limiting
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 8082,
|
||||
// WebSocket authentication during handshake
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for TOTP pending (should not allow terminal access during TOTP)
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection limits per user (max 3 concurrent connections)
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: We don't need to attach user info to request anymore
|
||||
// Connection handler will re-verify JWT directly from URL
|
||||
|
||||
sshLogger.info("WebSocket connection authenticated", {
|
||||
operation: "websocket_auth_success",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||
operation: "server_start",
|
||||
port: 8082,
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
// Linus principle: eliminate complexity - always parse JWT from URL directly
|
||||
let userId: string | undefined;
|
||||
let userPayload: any;
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token in connection", {
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "missing_token",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token in connection", {
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "invalid_token",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
userId = payload.userId;
|
||||
userPayload = payload;
|
||||
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket JWT verification failed during connection", error, {
|
||||
operation: "websocket_connection_auth_error",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data access permissions
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED"
|
||||
}));
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track user connections for limits
|
||||
if (!userConnections.has(userId)) {
|
||||
userConnections.set(userId, new Set());
|
||||
}
|
||||
const userWs = userConnections.get(userId)!;
|
||||
userWs.add(ws);
|
||||
|
||||
sshLogger.info("WebSocket connection established", {
|
||||
operation: "websocket_connection_established",
|
||||
userId,
|
||||
userConnections: userWs.size,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
// Clean up user connection tracking
|
||||
const userWs = userConnections.get(userId);
|
||||
if (userWs) {
|
||||
userWs.delete(ws);
|
||||
if (userWs.size === 0) {
|
||||
userConnections.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("WebSocket connection closed", {
|
||||
operation: "websocket_connection_closed",
|
||||
userId,
|
||||
remainingConnections: userWs?.size || 0
|
||||
});
|
||||
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
// Verify user still has data access before processing any messages
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired"
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED"
|
||||
}));
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_invalid_json",
|
||||
userId,
|
||||
messageLength: msg.toString().length,
|
||||
});
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
@@ -39,9 +225,14 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
// Ensure userId is attached to hostConfig for secure credential resolution
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
});
|
||||
@@ -82,7 +273,8 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_unknown_type",
|
||||
userId,
|
||||
messageType: type,
|
||||
});
|
||||
}
|
||||
@@ -187,21 +379,21 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
if (password) {
|
||||
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
} else {
|
||||
sshLogger.debug(`No password provided`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
}
|
||||
// SECURITY: Never log password information - removed password preview logging
|
||||
sshLogger.debug(`SSH authentication setup`, {
|
||||
operation: "terminal_ssh_auth_setup",
|
||||
userId,
|
||||
hostId: id,
|
||||
authType,
|
||||
hasPassword: !!password,
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
try {
|
||||
const credentials = await EncryptedDBOperations.select(
|
||||
db
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
@@ -211,6 +403,7 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
hostConfig.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -443,7 +636,7 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import cors from "cors";
|
||||
import { Client } from "ssh2";
|
||||
import { ChildProcess } from "child_process";
|
||||
import axios from "axios";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type {
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from "../../types/index.js";
|
||||
import { CONNECTION_STATES } from "../../types/index.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -43,6 +44,8 @@ const verificationTimers = new Map<string, NodeJS.Timeout>();
|
||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
|
||||
const countdownIntervals = new Map<string, NodeJS.Timeout>();
|
||||
const retryExhaustedTunnels = new Set<string>();
|
||||
const cleanupInProgress = new Set<string>();
|
||||
const tunnelConnecting = new Set<string>();
|
||||
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
||||
@@ -123,16 +126,37 @@ function getTunnelMarker(tunnelName: string) {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
function cleanupTunnelResources(tunnelName: string): void {
|
||||
function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void {
|
||||
tunnelLogger.info(`Cleaning up resources for tunnel '${tunnelName}' (force=${forceCleanup})`);
|
||||
|
||||
// Prevent concurrent cleanup operations
|
||||
if (cleanupInProgress.has(tunnelName)) {
|
||||
tunnelLogger.info(`Cleanup already in progress for '${tunnelName}', skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Protect connecting tunnels unless forced
|
||||
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
|
||||
tunnelLogger.info(`Tunnel '${tunnelName}' is connecting, skipping cleanup (use force=true to override)`);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupInProgress.add(tunnelName);
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
||||
if (tunnelConfig) {
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
if (err) {
|
||||
tunnelLogger.error(
|
||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
tunnelLogger.info(`Successfully cleaned up remote tunnel processes for '${tunnelName}'`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (activeTunnelProcesses.has(tunnelName)) {
|
||||
@@ -154,6 +178,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
try {
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (conn) {
|
||||
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
|
||||
conn.end();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -163,6 +188,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
);
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
|
||||
}
|
||||
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
@@ -203,6 +229,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
function resetRetryState(tunnelName: string): void {
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
@@ -394,7 +422,11 @@ async function connectSSHTunnel(
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupTunnelResources(tunnelName);
|
||||
// Mark tunnel as connecting to protect from cleanup
|
||||
tunnelConnecting.add(tunnelName);
|
||||
|
||||
// Force cleanup any existing resources before new connection
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
if (retryAttempt === 0) {
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
@@ -441,7 +473,7 @@ async function connectSSHTunnel(
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
||||
try {
|
||||
const credentials = await db
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
@@ -485,9 +517,35 @@ async function connectSSHTunnel(
|
||||
authMethod: tunnelConfig.endpointAuthMethod,
|
||||
};
|
||||
|
||||
tunnelLogger.info(`Source credentials for '${tunnelName}': authMethod=${resolvedSourceCredentials.authMethod}, hasPassword=${!!resolvedSourceCredentials.password}, hasSSHKey=${!!resolvedSourceCredentials.sshKey}`);
|
||||
tunnelLogger.info(`Final endpoint credentials for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}, credentialId=${tunnelConfig.endpointCredentialId}`);
|
||||
|
||||
// Validate that we have usable endpoint credentials
|
||||
if (resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password) {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
|
||||
tunnelLogger.error(errorMessage);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey) {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
|
||||
tunnelLogger.error(errorMessage);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
|
||||
try {
|
||||
const credentials = await db
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
@@ -506,6 +564,7 @@ async function connectSSHTunnel(
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
};
|
||||
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
|
||||
} else {
|
||||
tunnelLogger.warn("No endpoint credentials found in database", {
|
||||
operation: "tunnel_connect",
|
||||
@@ -555,6 +614,9 @@ async function connectSSHTunnel(
|
||||
clearTimeout(connectionTimeout);
|
||||
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||
|
||||
// Clear connecting state on error
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
@@ -583,6 +645,9 @@ async function connectSSHTunnel(
|
||||
conn.on("close", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
// Clear connecting state on close
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
@@ -620,11 +685,13 @@ async function connectSSHTunnel(
|
||||
resolvedEndpointCredentials.sshKey
|
||||
) {
|
||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
||||
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -v -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
|
||||
} else {
|
||||
tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
|
||||
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -v -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
||||
}
|
||||
|
||||
tunnelLogger.info(`Executing tunnel command for '${tunnelName}': ${tunnelCmd.replace(/sshpass -p '[^']*'/g, 'sshpass -p [HIDDEN]').replace(/echo '[^']*'/g, 'echo [HIDDEN]')}`);
|
||||
|
||||
conn.exec(tunnelCmd, (err, stream) => {
|
||||
if (err) {
|
||||
tunnelLogger.error(
|
||||
@@ -651,6 +718,9 @@ async function connectSSHTunnel(
|
||||
!manualDisconnects.has(tunnelName) &&
|
||||
activeTunnels.has(tunnelName)
|
||||
) {
|
||||
// Clear connecting state on successful connection
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: true,
|
||||
status: CONNECTION_STATES.CONNECTED,
|
||||
@@ -722,12 +792,52 @@ async function connectSSHTunnel(
|
||||
}
|
||||
});
|
||||
|
||||
stream.stdout?.on("data", (data: Buffer) => {});
|
||||
stream.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
tunnelLogger.info(`SSH stdout for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (err: Error) => {});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
if (errorMsg) {
|
||||
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
|
||||
|
||||
// Check for specific SSH errors
|
||||
if (errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found")) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for port forwarding errors
|
||||
if (errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed")) {
|
||||
const portMatch = errorMsg.match(/listen port (\d+)/);
|
||||
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
|
||||
|
||||
tunnelLogger.error(`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`);
|
||||
|
||||
// Close the connection immediately to prevent retries
|
||||
if (activeTunnels.has(tunnelName)) {
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (conn) {
|
||||
conn.end();
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
}
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: `Remote port forwarding failed for port ${port}. Port may be in use, requires root privileges, or SSH server doesn't allow port forwarding. Try a different port.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -763,7 +873,7 @@ async function connectSSHTunnel(
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
@@ -827,12 +937,54 @@ async function connectSSHTunnel(
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
function killRemoteTunnelByMarker(
|
||||
async function killRemoteTunnelByMarker(
|
||||
tunnelConfig: TunnelConfig,
|
||||
tunnelName: string,
|
||||
callback: (err?: Error) => void,
|
||||
) {
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
tunnelLogger.info(`Attempting to kill remote tunnel processes with marker '${tunnelMarker}' on source host ${tunnelConfig.sourceIP}`);
|
||||
|
||||
// Resolve source credentials using same logic as main tunnel connection
|
||||
let resolvedSourceCredentials = {
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
||||
try {
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
|
||||
tunnelName,
|
||||
credentialId: tunnelConfig.sourceCredentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const conn = new Client();
|
||||
const connOptions: any = {
|
||||
host: tunnelConfig.sourceIP,
|
||||
@@ -865,52 +1017,142 @@ function killRemoteTunnelByMarker(
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
|
||||
if (
|
||||
resolvedSourceCredentials.authMethod === "key" &&
|
||||
resolvedSourceCredentials.sshKey
|
||||
) {
|
||||
if (
|
||||
!tunnelConfig.sourceSSHKey.includes("-----BEGIN") ||
|
||||
!tunnelConfig.sourceSSHKey.includes("-----END")
|
||||
!resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
|
||||
!resolvedSourceCredentials.sshKey.includes("-----END")
|
||||
) {
|
||||
callback(new Error("Invalid SSH key format"));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = tunnelConfig.sourceSSHKey
|
||||
const cleanKey = resolvedSourceCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
if (resolvedSourceCredentials.keyPassword) {
|
||||
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
|
||||
}
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== "auto") {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
if (
|
||||
resolvedSourceCredentials.keyType &&
|
||||
resolvedSourceCredentials.keyType !== "auto"
|
||||
) {
|
||||
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
|
||||
}
|
||||
} else {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
connOptions.password = resolvedSourceCredentials.password;
|
||||
}
|
||||
|
||||
conn.on("ready", () => {
|
||||
const killCmd = `pkill -f '${tunnelMarker}'`;
|
||||
conn.exec(killCmd, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
stream.on("close", () => {
|
||||
conn.end();
|
||||
callback();
|
||||
// First, check for existing processes and get their PIDs
|
||||
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
|
||||
|
||||
conn.exec(checkCmd, (err, stream) => {
|
||||
let foundProcesses = false;
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
foundProcesses = true;
|
||||
tunnelLogger.info(`Found running tunnel processes for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (!foundProcesses) {
|
||||
tunnelLogger.info(`No running tunnel processes found for '${tunnelName}', cleanup not needed`);
|
||||
conn.end();
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute kill commands sequentially for better control
|
||||
const killCmds = [
|
||||
`pkill -TERM -f '${tunnelMarker}'`,
|
||||
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
|
||||
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
|
||||
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, // Force kill after delay
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
function executeNextKillCommand() {
|
||||
if (commandIndex >= killCmds.length) {
|
||||
// Final verification
|
||||
conn.exec(checkCmd, (err, verifyStream) => {
|
||||
let stillRunning = false;
|
||||
|
||||
verifyStream.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
stillRunning = true;
|
||||
tunnelLogger.warn(`Processes still running after cleanup for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
verifyStream.on("close", () => {
|
||||
if (!stillRunning) {
|
||||
tunnelLogger.info(`All tunnel processes successfully terminated for '${tunnelName}'`);
|
||||
} else {
|
||||
tunnelLogger.warn(`Some tunnel processes may still be running for '${tunnelName}'`);
|
||||
}
|
||||
conn.end();
|
||||
callback();
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const killCmd = killCmds[commandIndex];
|
||||
|
||||
conn.exec(killCmd, (err, stream) => {
|
||||
if (err) {
|
||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`);
|
||||
} else {
|
||||
tunnelLogger.info(`Executed kill command ${commandIndex + 1} for '${tunnelName}': ${killCmd.replace(/sleep \d+ && /, '')}`);
|
||||
}
|
||||
|
||||
stream.on("close", (code) => {
|
||||
tunnelLogger.info(`Kill command ${commandIndex + 1} completed with code ${code} for '${tunnelName}'`);
|
||||
commandIndex++;
|
||||
executeNextKillCommand();
|
||||
});
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
tunnelLogger.info(`Kill command ${commandIndex + 1} output for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output && !output.includes("debug1")) {
|
||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
executeNextKillCommand();
|
||||
});
|
||||
stream.on("data", () => {});
|
||||
stream.stderr.on("data", () => {});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
tunnelLogger.error(`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`);
|
||||
callback(err);
|
||||
});
|
||||
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
@@ -938,6 +1180,10 @@ app.post("/ssh/tunnel/connect", (req, res) => {
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
// Clean up any existing resources before starting new connection
|
||||
tunnelLogger.info(`Starting new connection for '${tunnelName}', cleaning up any existing resources`);
|
||||
cleanupTunnelResources(tunnelName);
|
||||
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
@@ -969,6 +1215,10 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Immediately clean up active connections (force cleanup)
|
||||
tunnelLogger.info(`Manual disconnect requested for '${tunnelName}', cleaning up resources`);
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
@@ -1005,6 +1255,10 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Immediately clean up active connections for cancel operation too (force cleanup)
|
||||
tunnelLogger.info(`Cancel requested for '${tunnelName}', cleaning up resources`);
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
@@ -1023,49 +1277,95 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
||||
|
||||
async function initializeAutoStartTunnels(): Promise<void> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
// Get internal auth token from SystemCrypto
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
// Get autostart hosts for tunnel configs
|
||||
const autostartResponse = await axios.get(
|
||||
"http://localhost:8081/ssh/db/host/internal",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Request": "1",
|
||||
"X-Internal-Auth-Token": internalAuthToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hosts: SSHHost[] = response.data || [];
|
||||
// Get all hosts for endpointHost resolution
|
||||
const allHostsResponse = await axios.get(
|
||||
"http://localhost:8081/ssh/db/host/internal/all",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Auth-Token": internalAuthToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const autostartHosts: SSHHost[] = autostartResponse.data || [];
|
||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||
const autoStartTunnels: TunnelConfig[] = [];
|
||||
|
||||
for (const host of hosts) {
|
||||
tunnelLogger.info(`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`);
|
||||
|
||||
for (const host of autostartHosts) {
|
||||
if (host.enableTunnel && host.tunnelConnections) {
|
||||
for (const tunnelConnection of host.tunnelConnections) {
|
||||
if (tunnelConnection.autoStart) {
|
||||
const endpointHost = hosts.find(
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnelConnection.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost,
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
tunnelLogger.info(`Setting up tunnel credentials for '${host.name || `${host.username}@${host.ip}`}' -> '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}': sourceAutostart=${!!host.autostartPassword}, endpointAutostart=${!!endpointHost.autostartPassword}, endpointEncrypted=${!!endpointHost.password}`);
|
||||
|
||||
// Debug: Log actual credential availability
|
||||
tunnelLogger.info(`Source host credentials debug:`, {
|
||||
hostId: host.id,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType
|
||||
});
|
||||
|
||||
tunnelLogger.info(`Endpoint host credentials debug:`, {
|
||||
hostId: endpointHost.id,
|
||||
hasAutostartPassword: !!endpointHost.autostartPassword,
|
||||
hasAutostartKey: !!endpointHost.autostartKey,
|
||||
hasEncryptedPassword: !!endpointHost.password,
|
||||
hasEncryptedKey: !!endpointHost.key,
|
||||
authType: endpointHost.authType
|
||||
});
|
||||
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.password,
|
||||
// Prefer autostart credentials for source host, fallback to encrypted credentials
|
||||
sourcePassword: host.autostartPassword || host.password,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.key,
|
||||
sourceKeyPassword: host.keyPassword,
|
||||
sourceSSHKey: host.autostartKey || host.key,
|
||||
sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
|
||||
sourceKeyType: host.keyType,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword: endpointHost.password,
|
||||
endpointAuthMethod: endpointHost.authType,
|
||||
endpointSSHKey: endpointHost.key,
|
||||
endpointKeyPassword: endpointHost.keyPassword,
|
||||
endpointKeyType: endpointHost.keyType,
|
||||
// Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
|
||||
endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
|
||||
endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
|
||||
endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
|
||||
endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
|
||||
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
endpointUserId: endpointHost.userId,
|
||||
sourcePort: tunnelConnection.sourcePort,
|
||||
endpointPort: tunnelConnection.endpointPort,
|
||||
maxRetries: tunnelConnection.maxRetries,
|
||||
@@ -1074,7 +1374,25 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
isPinned: host.pin,
|
||||
};
|
||||
|
||||
// Validate source and endpoint credentials availability
|
||||
const hasSourcePassword = host.autostartPassword;
|
||||
const hasSourceKey = host.autostartKey;
|
||||
const hasEndpointPassword = tunnelConnection.endpointPassword || endpointHost.autostartPassword;
|
||||
const hasEndpointKey = tunnelConnection.endpointKey || endpointHost.autostartKey;
|
||||
|
||||
if (!hasSourcePassword && !hasSourceKey) {
|
||||
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`);
|
||||
}
|
||||
|
||||
if (!hasEndpointPassword && !hasEndpointKey) {
|
||||
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`);
|
||||
}
|
||||
|
||||
autoStartTunnels.push(tunnelConfig);
|
||||
} else {
|
||||
tunnelLogger.error(
|
||||
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map(h => h.name || `${h.username}@${h.ip}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,150 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import "./database/database.js";
|
||||
import { DatabaseEncryption } from "./utils/database-encryption.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
import "dotenv/config";
|
||||
import dotenv from "dotenv";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { SystemCrypto } from "./utils/system-crypto.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Load persistent .env file from config directory if available (Docker)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
await fs.access('/app/config/.env');
|
||||
dotenv.config({ path: '/app/config/.env' });
|
||||
systemLogger.info("Loaded persistent configuration from /app/config/.env", {
|
||||
operation: "config_load"
|
||||
});
|
||||
} catch {
|
||||
// Config file doesn't exist yet, will be created on first run
|
||||
systemLogger.info("No persistent config found, will create on first run", {
|
||||
operation: "config_init"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const version = process.env.VERSION || "unknown";
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: "startup",
|
||||
version: version,
|
||||
});
|
||||
|
||||
// Auto-initialize SSL/TLS configuration
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
// Initialize database first - required before other services
|
||||
systemLogger.info("Initializing database...", {
|
||||
operation: "database_init"
|
||||
});
|
||||
const dbModule = await import("./database/db/index.js");
|
||||
await dbModule.databaseReady;
|
||||
systemLogger.success("Database initialized successfully", {
|
||||
operation: "database_init_complete"
|
||||
});
|
||||
|
||||
// Production environment security checks
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
systemLogger.info("Running production environment security checks...", {
|
||||
operation: "security_checks",
|
||||
});
|
||||
|
||||
const securityIssues: string[] = [];
|
||||
|
||||
// Check JWT and database keys (auto-generated if missing - warnings only)
|
||||
if (!process.env.JWT_SECRET) {
|
||||
systemLogger.warn("JWT_SECRET not set - using auto-generated keys (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
||||
});
|
||||
} else if (process.env.JWT_SECRET.length < 64) {
|
||||
securityIssues.push("JWT_SECRET should be at least 64 characters in production");
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_KEY) {
|
||||
systemLogger.warn("DATABASE_KEY not set - using auto-generated keys (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
||||
});
|
||||
} else if (process.env.DATABASE_KEY.length < 64) {
|
||||
securityIssues.push("DATABASE_KEY should be at least 64 characters in production");
|
||||
}
|
||||
|
||||
if (!process.env.INTERNAL_AUTH_TOKEN) {
|
||||
systemLogger.warn("INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated tokens are secure but not persistent across deployments"
|
||||
});
|
||||
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
|
||||
securityIssues.push("INTERNAL_AUTH_TOKEN should be at least 32 characters in production");
|
||||
}
|
||||
|
||||
// Check database file encryption
|
||||
if (process.env.DB_FILE_ENCRYPTION === 'false') {
|
||||
securityIssues.push("Database file encryption should be enabled in production");
|
||||
}
|
||||
|
||||
|
||||
// Check CORS configuration warning
|
||||
systemLogger.warn("Production deployment detected - ensure CORS is properly configured", {
|
||||
operation: "security_checks",
|
||||
warning: "Verify frontend domain whitelist"
|
||||
});
|
||||
|
||||
if (securityIssues.length > 0) {
|
||||
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
|
||||
operation: "security_checks_failed",
|
||||
issues: securityIssues,
|
||||
});
|
||||
for (const issue of securityIssues) {
|
||||
systemLogger.error(`- ${issue}`, { operation: "security_issue" });
|
||||
}
|
||||
systemLogger.error("Fix these issues before running in production!", {
|
||||
operation: "security_checks_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
systemLogger.success("Production security checks passed", {
|
||||
operation: "security_checks_complete",
|
||||
});
|
||||
}
|
||||
|
||||
systemLogger.info("Initializing backend services...", {
|
||||
operation: "startup",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
});
|
||||
|
||||
// Initialize database encryption before other services
|
||||
await DatabaseEncryption.initialize();
|
||||
systemLogger.info("Database encryption initialized", {
|
||||
operation: "encryption_init",
|
||||
// Initialize simplified authentication system
|
||||
const authManager = AuthManager.getInstance();
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
// Initialize system crypto keys (JWT, Database, Internal Auth)
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
systemLogger.info("Security system initialized (KEK-DEK architecture + SystemCrypto)", {
|
||||
operation: "security_init",
|
||||
});
|
||||
|
||||
// Load modules that depend on encryption after initialization
|
||||
// Load database-dependent modules after database initialization
|
||||
systemLogger.info("Starting database API server...", {
|
||||
operation: "api_server_init"
|
||||
});
|
||||
await import("./database/database.js");
|
||||
|
||||
// Load modules that depend on database and encryption
|
||||
systemLogger.info("Starting SSH services...", {
|
||||
operation: "ssh_services_init"
|
||||
});
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
@@ -43,6 +163,9 @@ import "dotenv/config";
|
||||
version: version,
|
||||
});
|
||||
|
||||
// Display SSL configuration info
|
||||
AutoSSLSetup.logSSLInfo();
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
|
||||
298
src/backend/utils/auth-manager.ts
Normal file
298
src/backend/utils/auth-manager.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
interface AuthenticationResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
userId?: string;
|
||||
isAdmin?: boolean;
|
||||
username?: string;
|
||||
requiresTOTP?: boolean;
|
||||
tempToken?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
pendingTOTP?: boolean;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthManager - Simplified authentication manager
|
||||
*
|
||||
* Responsibilities:
|
||||
* - JWT generation and validation
|
||||
* - Authentication middleware
|
||||
* - User login/logout
|
||||
*
|
||||
* No more two-layer sessions - use UserKeyManager directly
|
||||
*/
|
||||
class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
private systemCrypto: SystemCrypto;
|
||||
private userCrypto: UserCrypto;
|
||||
|
||||
private constructor() {
|
||||
this.systemCrypto = SystemCrypto.getInstance();
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
}
|
||||
|
||||
static getInstance(): AuthManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new AuthManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize authentication system
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.systemCrypto.initializeJWTSecret();
|
||||
databaseLogger.info("AuthManager initialized", {
|
||||
operation: "auth_init"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
*/
|
||||
async registerUser(userId: string, password: string): Promise<void> {
|
||||
await this.userCrypto.setupUserEncryption(userId, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* User login with lazy encryption migration
|
||||
*/
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateUser(userId, password);
|
||||
|
||||
if (authenticated) {
|
||||
// Trigger lazy encryption migration for user's sensitive fields
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform lazy encryption migration for user's sensitive data
|
||||
* This runs asynchronously after successful login
|
||||
*/
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
databaseLogger.warn("Cannot perform lazy encryption migration - user data key not available", {
|
||||
operation: "lazy_encryption_migration_no_key",
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Import database connection - need to access raw SQLite for migration
|
||||
const { getSqlite, saveMemoryDatabaseToFile, databaseReady } = await import("../database/db/index.js");
|
||||
|
||||
// Ensure database is fully initialized before accessing SQLite
|
||||
await databaseReady;
|
||||
const sqlite = getSqlite();
|
||||
|
||||
// Perform the migration
|
||||
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
||||
userId,
|
||||
userDataKey,
|
||||
sqlite
|
||||
);
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
// Save the in-memory database to disk to persist the migration
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
databaseLogger.success("Lazy encryption migration completed for user", {
|
||||
operation: "lazy_encryption_migration_success",
|
||||
userId,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
||||
});
|
||||
} else {
|
||||
databaseLogger.debug("No lazy encryption migration needed for user", {
|
||||
operation: "lazy_encryption_migration_not_needed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Log error but don't fail the login process
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT Token
|
||||
*/
|
||||
async generateJWTToken(
|
||||
userId: string,
|
||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {}
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
if (options.pendingTOTP) {
|
||||
payload.pendingTOTP = true;
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: options.expiresIn || "24h"
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT Token
|
||||
*/
|
||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("JWT verification failed", {
|
||||
operation: "jwt_verify_failed",
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*/
|
||||
createAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Data access middleware - requires user to have unlocked data
|
||||
*/
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
return res.status(423).json({
|
||||
error: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED"
|
||||
});
|
||||
}
|
||||
|
||||
(req as any).dataKey = dataKey;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin middleware - requires user to be authenticated and have admin privileges
|
||||
*/
|
||||
createAdminMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
try {
|
||||
const { db } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, payload.userId));
|
||||
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
databaseLogger.warn("Non-admin user attempted to access admin endpoint", {
|
||||
operation: "admin_access_denied",
|
||||
userId: payload.userId,
|
||||
endpoint: req.path,
|
||||
});
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to verify admin privileges", error, {
|
||||
operation: "admin_check_failed",
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to verify admin privileges" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User logout
|
||||
*/
|
||||
logoutUser(userId: string): void {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key
|
||||
*/
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is unlocked
|
||||
*/
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
||||
return await this.userCrypto.changeUserPassword(userId, oldPassword, newPassword);
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthManager, type AuthenticationResult, type JWTPayload };
|
||||
261
src/backend/utils/auto-ssl-setup.ts
Normal file
261
src/backend/utils/auto-ssl-setup.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { execSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Auto SSL Setup - Optional SSL certificate generation for Termix
|
||||
*
|
||||
* Linus principle: Simple defaults, optional security features
|
||||
* - SSL disabled by default to avoid setup complexity
|
||||
* - Auto-generates SSL certificates when enabled
|
||||
* - Uses container-appropriate paths
|
||||
* - Users can enable SSL by setting ENABLE_SSL=true
|
||||
*/
|
||||
export class AutoSSLSetup {
|
||||
private static readonly SSL_DIR = path.join(process.cwd(), "ssl");
|
||||
private static readonly CERT_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.crt");
|
||||
private static readonly KEY_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.key");
|
||||
private static readonly ENV_FILE = path.join(process.cwd(), ".env");
|
||||
|
||||
/**
|
||||
* Initialize SSL setup automatically during system startup
|
||||
*/
|
||||
static async initialize(): Promise<void> {
|
||||
try {
|
||||
systemLogger.info("🔐 Initializing SSL/TLS configuration...", {
|
||||
operation: "ssl_auto_init"
|
||||
});
|
||||
|
||||
// Check if SSL is already properly configured
|
||||
if (await this.isSSLConfigured()) {
|
||||
systemLogger.info("✅ SSL configuration already exists and is valid", {
|
||||
operation: "ssl_already_configured"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate SSL certificates
|
||||
await this.generateSSLCertificates();
|
||||
|
||||
// Setup environment variables for SSL
|
||||
await this.setupEnvironmentVariables();
|
||||
|
||||
systemLogger.success("🚀 SSL/TLS configuration completed successfully", {
|
||||
operation: "ssl_auto_init_complete",
|
||||
https_port: process.env.SSL_PORT || "8443",
|
||||
note: "HTTPS/WSS is now enabled by default"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
systemLogger.error("❌ Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed"
|
||||
});
|
||||
|
||||
// Don't crash the application - fallback to HTTP
|
||||
systemLogger.warn("⚠️ Falling back to HTTP-only mode", {
|
||||
operation: "ssl_fallback_http"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SSL is already properly configured
|
||||
*/
|
||||
private static async isSSLConfigured(): Promise<boolean> {
|
||||
try {
|
||||
// Check if certificate files exist
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
// Check if certificate is still valid (at least 30 days)
|
||||
const result = execSync(`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SSL certificates automatically
|
||||
*/
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
systemLogger.info("🔑 Generating SSL certificates for local development...", {
|
||||
operation: "ssl_cert_generation"
|
||||
});
|
||||
|
||||
try {
|
||||
// Create SSL directory
|
||||
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
||||
|
||||
// Create OpenSSL config for comprehensive certificate
|
||||
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
||||
const opensslConfig = `
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=localhost
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
DNS.4 = termix.local
|
||||
DNS.5 = *.termix.local
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
`.trim();
|
||||
|
||||
await fs.writeFile(configFile, opensslConfig);
|
||||
|
||||
// Generate private key
|
||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, { stdio: 'pipe' });
|
||||
|
||||
// Generate certificate
|
||||
execSync(`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`, {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Set proper permissions
|
||||
await fs.chmod(this.KEY_FILE, 0o600);
|
||||
await fs.chmod(this.CERT_FILE, 0o644);
|
||||
|
||||
// Clean up temp config
|
||||
await fs.unlink(configFile);
|
||||
|
||||
systemLogger.success("✅ SSL certificates generated successfully", {
|
||||
operation: "ssl_cert_generated",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
valid_days: 365
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`SSL certificate generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup environment variables for SSL configuration
|
||||
*/
|
||||
private static async setupEnvironmentVariables(): Promise<void> {
|
||||
systemLogger.info("⚙️ Configuring SSL environment variables...", {
|
||||
operation: "ssl_env_setup"
|
||||
});
|
||||
|
||||
// Use container paths in production, local paths in development
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const certPath = isProduction ? "/app/ssl/termix.crt" : this.CERT_FILE;
|
||||
const keyPath = isProduction ? "/app/ssl/termix.key" : this.KEY_FILE;
|
||||
|
||||
const sslEnvVars = {
|
||||
ENABLE_SSL: "false", // Disable SSL by default to avoid setup issues
|
||||
SSL_PORT: process.env.SSL_PORT || "8443",
|
||||
SSL_CERT_PATH: certPath,
|
||||
SSL_KEY_PATH: keyPath,
|
||||
SSL_DOMAIN: "localhost"
|
||||
};
|
||||
|
||||
// Check if .env file exists
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, 'utf8');
|
||||
} catch {
|
||||
// .env doesn't exist, will create new one
|
||||
}
|
||||
|
||||
// Update or add SSL variables
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
||||
|
||||
if (regex.test(updatedContent)) {
|
||||
// Update existing variable
|
||||
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
// Add new variable
|
||||
if (!updatedContent.includes(`# SSL Configuration`)) {
|
||||
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
||||
}
|
||||
updatedContent += `${key}=${value}\n`;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated .env file if there are changes
|
||||
if (hasChanges || !envContent) {
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
||||
|
||||
systemLogger.info("✅ SSL environment variables configured", {
|
||||
operation: "ssl_env_configured",
|
||||
file: this.ENV_FILE,
|
||||
variables: Object.keys(sslEnvVars)
|
||||
});
|
||||
}
|
||||
|
||||
// Update process.env for current session
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSL configuration for nginx/server
|
||||
*/
|
||||
static getSSLConfig() {
|
||||
return {
|
||||
enabled: process.env.ENABLE_SSL === "true",
|
||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||
domain: process.env.SSL_DOMAIN || "localhost"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display SSL setup information
|
||||
*/
|
||||
static logSSLInfo(): void {
|
||||
const config = this.getSSLConfig();
|
||||
|
||||
if (config.enabled) {
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ 🔒 Termix SSL/TLS Enabled ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ HTTPS Port: ${config.port.toString().padEnd(47)} ║
|
||||
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} ║
|
||||
║ Domain: ${config.domain.padEnd(47)} ║
|
||||
║ ║
|
||||
║ 🌐 Access URLs: ║
|
||||
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)} ║
|
||||
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} ║
|
||||
║ ║
|
||||
║ 🔐 WebSocket connections automatically use WSS over HTTPS ║
|
||||
║ ⚠️ Self-signed certificate will show browser warnings ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
313
src/backend/utils/data-crypto.ts
Normal file
313
src/backend/utils/data-crypto.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { LazyFieldEncryption } from "./lazy-field-encryption.js";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* DataCrypto - Simplified database encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - Remove all "backward compatibility" garbage
|
||||
* - Remove all special case handling
|
||||
* - Data is either properly encrypted or operation fails
|
||||
* - No legacy data concept
|
||||
*/
|
||||
class DataCrypto {
|
||||
private static userCrypto: UserCrypto;
|
||||
|
||||
static initialize() {
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
databaseLogger.info("DataCrypto initialized - no legacy compatibility", {
|
||||
operation: "data_crypto_init",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt record - simple and direct
|
||||
*/
|
||||
static encryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
||||
const encryptedRecord = { ...record };
|
||||
const recordId = record.id || 'temp-' + Date.now();
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt record with lazy encryption support
|
||||
* Handles both encrypted and plaintext fields (from migration)
|
||||
*/
|
||||
static decryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
||||
if (!record) return record;
|
||||
|
||||
const decryptedRecord = { ...record };
|
||||
const recordId = record.id;
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
// Use lazy encryption to handle both plaintext and encrypted data
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch decrypt
|
||||
*/
|
||||
static decryptRecords(tableName: string, records: any[], userId: string, userDataKey: Buffer): any[] {
|
||||
if (!Array.isArray(records)) return records;
|
||||
return records.map((record) => this.decryptRecord(tableName, record, userId, userDataKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate user's plaintext sensitive fields to encrypted format
|
||||
* Called during user login to gradually encrypt legacy data
|
||||
*/
|
||||
static async migrateUserSensitiveFields(
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
db: any
|
||||
): Promise<{
|
||||
migrated: boolean;
|
||||
migratedTables: string[];
|
||||
migratedFieldsCount: number;
|
||||
}> {
|
||||
let migrated = false;
|
||||
const migratedTables: string[] = [];
|
||||
let migratedFieldsCount = 0;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user sensitive fields migration", {
|
||||
operation: "user_sensitive_migration_start",
|
||||
userId,
|
||||
});
|
||||
|
||||
// Check if migration is needed
|
||||
const { needsMigration, plaintextFields } = await LazyFieldEncryption.checkUserNeedsMigration(
|
||||
userId,
|
||||
userDataKey,
|
||||
db
|
||||
);
|
||||
|
||||
if (!needsMigration) {
|
||||
databaseLogger.info("No migration needed for user", {
|
||||
operation: "user_sensitive_migration_not_needed",
|
||||
userId,
|
||||
});
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
|
||||
databaseLogger.info("User requires sensitive field migration", {
|
||||
operation: "user_sensitive_migration_required",
|
||||
userId,
|
||||
plaintextFieldsCount: plaintextFields.length,
|
||||
});
|
||||
|
||||
// Process ssh_data table
|
||||
const sshDataRecords = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
|
||||
for (const record of sshDataRecords) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_data');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString()
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE ssh_data
|
||||
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
record.id
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('ssh_data')) {
|
||||
migratedTables.push('ssh_data');
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process ssh_credentials table
|
||||
const sshCredentialsRecords = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
||||
for (const record of sshCredentialsRecords) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_credentials');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString()
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE ssh_credentials
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.private_key || null,
|
||||
record.id
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('ssh_credentials')) {
|
||||
migratedTables.push('ssh_credentials');
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process users table
|
||||
const userRecord = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
if (userRecord) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('users');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.totp_secret || null,
|
||||
updatedRecord.totp_backup_codes || null,
|
||||
userId
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('users')) {
|
||||
migratedTables.push('users');
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
databaseLogger.success("User sensitive fields migration completed", {
|
||||
operation: "user_sensitive_migration_success",
|
||||
userId,
|
||||
migratedTables,
|
||||
migratedFieldsCount,
|
||||
});
|
||||
}
|
||||
|
||||
return { migrated, migratedTables, migratedFieldsCount };
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||
operation: "user_sensitive_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
// Don't throw error to avoid breaking user login
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key
|
||||
*/
|
||||
static getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user access permissions - simple and direct
|
||||
*/
|
||||
static validateUserAccess(userId: string): Buffer {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error(`User ${userId} data not unlocked`);
|
||||
}
|
||||
return userDataKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: automatically get user key and encrypt
|
||||
*/
|
||||
static encryptRecordForUser(tableName: string, record: any, userId: string): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.encryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: automatically get user key and decrypt
|
||||
*/
|
||||
static decryptRecordForUser(tableName: string, record: any, userId: string): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: batch decrypt
|
||||
*/
|
||||
static decryptRecordsForUser(tableName: string, records: any[], userId: string): any[] {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecords(tableName, records, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access data
|
||||
*/
|
||||
static canUserAccessData(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption functionality
|
||||
*/
|
||||
static testUserEncryption(userId: string): boolean {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) return false;
|
||||
|
||||
const testData = "test-" + Date.now();
|
||||
const encrypted = FieldCrypto.encryptField(testData, userDataKey, "test-record", "test-field");
|
||||
const decrypted = FieldCrypto.decryptField(encrypted, userDataKey, "test-record", "test-field");
|
||||
|
||||
return decrypted === testData;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
@@ -1,287 +0,0 @@
|
||||
import { FieldEncryption } from "./encryption.js";
|
||||
import { EncryptionKeyManager } from "./encryption-key-manager.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface EncryptionContext {
|
||||
masterPassword: string;
|
||||
encryptionEnabled: boolean;
|
||||
forceEncryption: boolean;
|
||||
migrateOnAccess: boolean;
|
||||
}
|
||||
|
||||
class DatabaseEncryption {
|
||||
private static context: EncryptionContext | null = null;
|
||||
|
||||
static async initialize(config: Partial<EncryptionContext> = {}) {
|
||||
const keyManager = EncryptionKeyManager.getInstance();
|
||||
const masterPassword =
|
||||
config.masterPassword || (await keyManager.initializeKey());
|
||||
|
||||
this.context = {
|
||||
masterPassword,
|
||||
encryptionEnabled: config.encryptionEnabled ?? true,
|
||||
forceEncryption: config.forceEncryption ?? false,
|
||||
migrateOnAccess: config.migrateOnAccess ?? true,
|
||||
};
|
||||
|
||||
databaseLogger.info("Database encryption initialized", {
|
||||
operation: "encryption_init",
|
||||
enabled: this.context.encryptionEnabled,
|
||||
forceEncryption: this.context.forceEncryption,
|
||||
dynamicKey: !config.masterPassword,
|
||||
});
|
||||
}
|
||||
|
||||
static getContext(): EncryptionContext {
|
||||
if (!this.context) {
|
||||
throw new Error(
|
||||
"DatabaseEncryption not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
return this.context;
|
||||
}
|
||||
|
||||
static encryptRecord(tableName: string, record: any): any {
|
||||
const context = this.getContext();
|
||||
if (!context.encryptionEnabled) return record;
|
||||
|
||||
const encryptedRecord = { ...record };
|
||||
let hasEncryption = false;
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
|
||||
try {
|
||||
const fieldKey = FieldEncryption.getFieldKey(
|
||||
context.masterPassword,
|
||||
`${tableName}.${fieldName}`,
|
||||
);
|
||||
encryptedRecord[fieldName] = FieldEncryption.encryptField(
|
||||
value as string,
|
||||
fieldKey,
|
||||
);
|
||||
hasEncryption = true;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to encrypt field ${tableName}.${fieldName}`,
|
||||
error,
|
||||
{
|
||||
operation: "field_encryption",
|
||||
table: tableName,
|
||||
field: fieldName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEncryption) {
|
||||
databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, {
|
||||
operation: "record_encryption",
|
||||
table: tableName,
|
||||
});
|
||||
}
|
||||
|
||||
return encryptedRecord;
|
||||
}
|
||||
|
||||
static decryptRecord(tableName: string, record: any): any {
|
||||
const context = this.getContext();
|
||||
if (!record) return record;
|
||||
|
||||
const decryptedRecord = { ...record };
|
||||
let hasDecryption = false;
|
||||
let needsMigration = false;
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
|
||||
try {
|
||||
const fieldKey = FieldEncryption.getFieldKey(
|
||||
context.masterPassword,
|
||||
`${tableName}.${fieldName}`,
|
||||
);
|
||||
|
||||
if (FieldEncryption.isEncrypted(value as string)) {
|
||||
decryptedRecord[fieldName] = FieldEncryption.decryptField(
|
||||
value as string,
|
||||
fieldKey,
|
||||
);
|
||||
hasDecryption = true;
|
||||
} else if (context.encryptionEnabled && !context.forceEncryption) {
|
||||
decryptedRecord[fieldName] = value;
|
||||
needsMigration = context.migrateOnAccess;
|
||||
} else if (context.forceEncryption) {
|
||||
databaseLogger.warn(
|
||||
`Unencrypted field detected in force encryption mode`,
|
||||
{
|
||||
operation: "decryption_warning",
|
||||
table: tableName,
|
||||
field: fieldName,
|
||||
},
|
||||
);
|
||||
decryptedRecord[fieldName] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to decrypt field ${tableName}.${fieldName}`,
|
||||
error,
|
||||
{
|
||||
operation: "field_decryption",
|
||||
table: tableName,
|
||||
field: fieldName,
|
||||
},
|
||||
);
|
||||
|
||||
if (context.forceEncryption) {
|
||||
throw error;
|
||||
} else {
|
||||
decryptedRecord[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
this.scheduleFieldMigration(tableName, record);
|
||||
}
|
||||
|
||||
return decryptedRecord;
|
||||
}
|
||||
|
||||
static decryptRecords(tableName: string, records: any[]): any[] {
|
||||
if (!Array.isArray(records)) return records;
|
||||
return records.map((record) => this.decryptRecord(tableName, record));
|
||||
}
|
||||
|
||||
private static scheduleFieldMigration(tableName: string, record: any) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.migrateRecord(tableName, record);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to migrate record ${tableName}:${record.id}`,
|
||||
error,
|
||||
{
|
||||
operation: "migration_failed",
|
||||
table: tableName,
|
||||
recordId: record.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
static async migrateRecord(tableName: string, record: any): Promise<any> {
|
||||
const context = this.getContext();
|
||||
if (!context.encryptionEnabled || !context.migrateOnAccess) return record;
|
||||
|
||||
let needsUpdate = false;
|
||||
const updatedRecord = { ...record };
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (
|
||||
FieldEncryption.shouldEncryptField(tableName, fieldName) &&
|
||||
value &&
|
||||
!FieldEncryption.isEncrypted(value as string)
|
||||
) {
|
||||
try {
|
||||
const fieldKey = FieldEncryption.getFieldKey(
|
||||
context.masterPassword,
|
||||
`${tableName}.${fieldName}`,
|
||||
);
|
||||
updatedRecord[fieldName] = FieldEncryption.encryptField(
|
||||
value as string,
|
||||
fieldKey,
|
||||
);
|
||||
needsUpdate = true;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to migrate field ${tableName}.${fieldName}`,
|
||||
error,
|
||||
{
|
||||
operation: "field_migration",
|
||||
table: tableName,
|
||||
field: fieldName,
|
||||
recordId: record.id,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
|
||||
static validateConfiguration(): boolean {
|
||||
try {
|
||||
const context = this.getContext();
|
||||
const testData = "test-encryption-data";
|
||||
const testKey = FieldEncryption.getFieldKey(
|
||||
context.masterPassword,
|
||||
"test",
|
||||
);
|
||||
|
||||
const encrypted = FieldEncryption.encryptField(testData, testKey);
|
||||
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
|
||||
|
||||
return decrypted === testData;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Encryption configuration validation failed",
|
||||
error,
|
||||
{
|
||||
operation: "config_validation",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getEncryptionStatus() {
|
||||
try {
|
||||
const context = this.getContext();
|
||||
return {
|
||||
enabled: context.encryptionEnabled,
|
||||
forceEncryption: context.forceEncryption,
|
||||
migrateOnAccess: context.migrateOnAccess,
|
||||
configValid: this.validateConfiguration(),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
enabled: false,
|
||||
forceEncryption: false,
|
||||
migrateOnAccess: false,
|
||||
configValid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async getDetailedStatus() {
|
||||
const keyManager = EncryptionKeyManager.getInstance();
|
||||
const keyStatus = await keyManager.getEncryptionStatus();
|
||||
const encryptionStatus = this.getEncryptionStatus();
|
||||
|
||||
return {
|
||||
...encryptionStatus,
|
||||
key: keyStatus,
|
||||
initialized: this.context !== null,
|
||||
};
|
||||
}
|
||||
|
||||
static async reinitializeWithNewKey(): Promise<void> {
|
||||
const keyManager = EncryptionKeyManager.getInstance();
|
||||
const newKey = await keyManager.regenerateKey();
|
||||
|
||||
this.context = null;
|
||||
await this.initialize({ masterPassword: newKey });
|
||||
|
||||
databaseLogger.warn("Database encryption reinitialized with new key", {
|
||||
operation: "encryption_reinit",
|
||||
requiresMigration: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { DatabaseEncryption };
|
||||
export type { EncryptionContext };
|
||||
@@ -1,55 +1,45 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { HardwareFingerprint } from "./hardware-fingerprint.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
|
||||
interface EncryptedFileMetadata {
|
||||
iv: string;
|
||||
tag: string;
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
salt: string;
|
||||
algorithm: string;
|
||||
keySource?: string; // Track where the key comes from (SystemCrypto) - v2 only
|
||||
salt?: string; // Legacy v1 format only
|
||||
}
|
||||
|
||||
/**
|
||||
* Database file encryption - encrypts the entire SQLite database file at rest
|
||||
* This provides an additional security layer on top of field-level encryption
|
||||
* Uses SystemCrypto for key management - no more fixed seed garbage!
|
||||
*
|
||||
* Linus principles applied:
|
||||
* - Remove hardcoded keys security disaster
|
||||
* - Use SystemCrypto instance keys for proper per-instance security
|
||||
* - Simple and direct, no complex key derivation
|
||||
*/
|
||||
class DatabaseFileEncryption {
|
||||
private static readonly VERSION = "v1";
|
||||
private static readonly VERSION = "v2";
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
private static readonly KEY_ITERATIONS = 100000;
|
||||
private static readonly ENCRYPTED_FILE_SUFFIX = ".encrypted";
|
||||
private static readonly METADATA_FILE_SUFFIX = ".meta";
|
||||
|
||||
/**
|
||||
* Generate file encryption key from hardware fingerprint
|
||||
*/
|
||||
private static generateFileEncryptionKey(salt: Buffer): Buffer {
|
||||
const hardwareFingerprint = HardwareFingerprint.generate();
|
||||
|
||||
const key = crypto.pbkdf2Sync(
|
||||
hardwareFingerprint,
|
||||
salt,
|
||||
this.KEY_ITERATIONS,
|
||||
32, // 256 bits for AES-256
|
||||
"sha256",
|
||||
);
|
||||
|
||||
return key;
|
||||
}
|
||||
private static systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
/**
|
||||
* Encrypt database from buffer (for in-memory databases)
|
||||
*/
|
||||
static encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): string {
|
||||
static async encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): Promise<string> {
|
||||
try {
|
||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
// Generate encryption components
|
||||
const salt = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const key = this.generateFileEncryptionKey(salt);
|
||||
|
||||
// Encrypt the buffer
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
@@ -61,9 +51,9 @@ class DatabaseFileEncryption {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||
salt: salt.toString("hex"),
|
||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
// Write encrypted file and metadata
|
||||
@@ -86,7 +76,7 @@ class DatabaseFileEncryption {
|
||||
/**
|
||||
* Encrypt database file
|
||||
*/
|
||||
static encryptDatabaseFile(sourcePath: string, targetPath?: string): string {
|
||||
static async encryptDatabaseFile(sourcePath: string, targetPath?: string): Promise<string> {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Source database file does not exist: ${sourcePath}`);
|
||||
}
|
||||
@@ -99,10 +89,11 @@ class DatabaseFileEncryption {
|
||||
// Read source file
|
||||
const sourceData = fs.readFileSync(sourcePath);
|
||||
|
||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
// Generate encryption components
|
||||
const salt = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const key = this.generateFileEncryptionKey(salt);
|
||||
|
||||
// Encrypt the file
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
@@ -117,9 +108,9 @@ class DatabaseFileEncryption {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: HardwareFingerprint.generate().substring(0, 16),
|
||||
salt: salt.toString("hex"),
|
||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
// Write encrypted file and metadata
|
||||
@@ -151,7 +142,7 @@ class DatabaseFileEncryption {
|
||||
/**
|
||||
* Decrypt database file to buffer (for in-memory usage)
|
||||
*/
|
||||
static decryptDatabaseToBuffer(encryptedPath: string): Buffer {
|
||||
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
|
||||
if (!fs.existsSync(encryptedPath)) {
|
||||
throw new Error(
|
||||
`Encrypted database file does not exist: ${encryptedPath}`,
|
||||
@@ -168,28 +159,29 @@ class DatabaseFileEncryption {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
// Validate metadata version
|
||||
if (metadata.version !== this.VERSION) {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Validate hardware fingerprint
|
||||
const currentFingerprint = HardwareFingerprint.generate().substring(
|
||||
0,
|
||||
16,
|
||||
);
|
||||
if (metadata.fingerprint !== currentFingerprint) {
|
||||
throw new Error(
|
||||
"Hardware fingerprint mismatch - database was encrypted on different hardware",
|
||||
);
|
||||
}
|
||||
|
||||
// Read encrypted data
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
// Generate decryption key
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const key = this.generateFileEncryptionKey(salt);
|
||||
// Get decryption key based on version
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
// New v2 format: use SystemCrypto key
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
// Legacy v1 format: use deprecated salt-based key derivation
|
||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath
|
||||
});
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Decrypt to buffer
|
||||
const decipher = crypto.createDecipheriv(
|
||||
@@ -219,10 +211,10 @@ class DatabaseFileEncryption {
|
||||
/**
|
||||
* Decrypt database file
|
||||
*/
|
||||
static decryptDatabaseFile(
|
||||
static async decryptDatabaseFile(
|
||||
encryptedPath: string,
|
||||
targetPath?: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(encryptedPath)) {
|
||||
throw new Error(
|
||||
`Encrypted database file does not exist: ${encryptedPath}`,
|
||||
@@ -242,33 +234,29 @@ class DatabaseFileEncryption {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
// Validate metadata version
|
||||
if (metadata.version !== this.VERSION) {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Validate hardware fingerprint
|
||||
const currentFingerprint = HardwareFingerprint.generate().substring(
|
||||
0,
|
||||
16,
|
||||
);
|
||||
if (metadata.fingerprint !== currentFingerprint) {
|
||||
databaseLogger.warn("Hardware fingerprint mismatch for database file", {
|
||||
operation: "database_file_decryption",
|
||||
expected: metadata.fingerprint,
|
||||
current: currentFingerprint,
|
||||
});
|
||||
throw new Error(
|
||||
"Hardware fingerprint mismatch - database was encrypted on different hardware",
|
||||
);
|
||||
}
|
||||
|
||||
// Read encrypted data
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
// Generate decryption key
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const key = this.generateFileEncryptionKey(salt);
|
||||
// Get decryption key based on version
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
// New v2 format: use SystemCrypto key
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
// Legacy v1 format: use deprecated salt-based key derivation
|
||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath
|
||||
});
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Decrypt the file
|
||||
const decipher = crypto.createDecipheriv(
|
||||
@@ -350,16 +338,13 @@ class DatabaseFileEncryption {
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const fileStats = fs.statSync(encryptedPath);
|
||||
const currentFingerprint = HardwareFingerprint.generate().substring(
|
||||
0,
|
||||
16,
|
||||
);
|
||||
const currentFingerprint = "termix-v1-file"; // Fixed identifier
|
||||
|
||||
return {
|
||||
version: metadata.version,
|
||||
algorithm: metadata.algorithm,
|
||||
fingerprint: metadata.fingerprint,
|
||||
isCurrentHardware: metadata.fingerprint === currentFingerprint,
|
||||
isCurrentHardware: true, // Hardware validation removed
|
||||
fileSize: fileStats.size,
|
||||
};
|
||||
} catch {
|
||||
@@ -370,10 +355,10 @@ class DatabaseFileEncryption {
|
||||
/**
|
||||
* Securely backup database by creating encrypted copy
|
||||
*/
|
||||
static createEncryptedBackup(
|
||||
static async createEncryptedBackup(
|
||||
databasePath: string,
|
||||
backupDir: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
throw new Error(`Database file does not exist: ${databasePath}`);
|
||||
}
|
||||
@@ -389,7 +374,7 @@ class DatabaseFileEncryption {
|
||||
const backupPath = path.join(backupDir, backupFileName);
|
||||
|
||||
try {
|
||||
const encryptedPath = this.encryptDatabaseFile(databasePath, backupPath);
|
||||
const encryptedPath = await this.encryptDatabaseFile(databasePath, backupPath);
|
||||
|
||||
databaseLogger.info("Encrypted database backup created", {
|
||||
operation: "database_backup",
|
||||
@@ -412,16 +397,16 @@ class DatabaseFileEncryption {
|
||||
/**
|
||||
* Restore database from encrypted backup
|
||||
*/
|
||||
static restoreFromEncryptedBackup(
|
||||
static async restoreFromEncryptedBackup(
|
||||
backupPath: string,
|
||||
targetPath: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
if (!this.isEncryptedDatabaseFile(backupPath)) {
|
||||
throw new Error("Invalid encrypted backup file");
|
||||
}
|
||||
|
||||
try {
|
||||
const restoredPath = this.decryptDatabaseFile(backupPath, targetPath);
|
||||
const restoredPath = await this.decryptDatabaseFile(backupPath, targetPath);
|
||||
|
||||
databaseLogger.info("Database restored from encrypted backup", {
|
||||
operation: "database_restore",
|
||||
@@ -440,17 +425,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hardware compatibility for encrypted file
|
||||
*/
|
||||
static validateHardwareCompatibility(encryptedPath: string): boolean {
|
||||
try {
|
||||
const info = this.getEncryptedFileInfo(encryptedPath);
|
||||
return info?.isCurrentHardware ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary files
|
||||
|
||||
@@ -1,504 +1,457 @@
|
||||
import Database from "better-sqlite3";
|
||||
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 { DatabaseFileEncryption } from "./database-file-encryption.js";
|
||||
|
||||
interface ExportMetadata {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
exportId: string;
|
||||
sourceHardwareFingerprint: string;
|
||||
tableCount: number;
|
||||
recordCount: number;
|
||||
encryptedFields: string[];
|
||||
}
|
||||
|
||||
interface MigrationExport {
|
||||
metadata: ExportMetadata;
|
||||
data: {
|
||||
[tableName: string]: any[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
export interface MigrationResult {
|
||||
success: boolean;
|
||||
imported: {
|
||||
tables: number;
|
||||
records: number;
|
||||
};
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
migratedTables: number;
|
||||
migratedRows: number;
|
||||
backupPath?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database migration utility for exporting/importing data between different hardware
|
||||
* Handles both field-level and file-level encryption/decryption during migration
|
||||
*/
|
||||
class DatabaseMigration {
|
||||
private static readonly VERSION = "v1";
|
||||
private static readonly EXPORT_FILE_EXTENSION = ".termix-export.json";
|
||||
export interface MigrationStatus {
|
||||
needsMigration: boolean;
|
||||
hasUnencryptedDb: boolean;
|
||||
hasEncryptedDb: boolean;
|
||||
unencryptedDbSize: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class DatabaseMigration {
|
||||
private dataDir: string;
|
||||
private unencryptedDbPath: string;
|
||||
private encryptedDbPath: string;
|
||||
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir;
|
||||
this.unencryptedDbPath = path.join(dataDir, "db.sqlite");
|
||||
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export database for migration
|
||||
* Decrypts all encrypted fields for transport to new hardware
|
||||
* 检查是否需要迁移以及迁移状态
|
||||
*/
|
||||
static async exportDatabase(exportPath?: string): Promise<string> {
|
||||
const exportId = crypto.randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const defaultExportPath = path.join(
|
||||
databasePaths.directory,
|
||||
`termix-export-${timestamp.replace(/[:.]/g, "-")}${this.EXPORT_FILE_EXTENSION}`,
|
||||
);
|
||||
const actualExportPath = exportPath || defaultExportPath;
|
||||
checkMigrationStatus(): MigrationStatus {
|
||||
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
|
||||
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath);
|
||||
|
||||
let unencryptedDbSize = 0;
|
||||
if (hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedDbSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not get unencrypted database file size", {
|
||||
operation: "migration_status_check",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 确定迁移状态
|
||||
let needsMigration = false;
|
||||
let reason = "";
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
// 两个都存在:可能是之前迁移失败或中断
|
||||
needsMigration = false;
|
||||
reason = "Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
||||
} else if (hasEncryptedDb && !hasUnencryptedDb) {
|
||||
// 只有加密数据库:无需迁移
|
||||
needsMigration = false;
|
||||
reason = "Only encrypted database exists. No migration needed.";
|
||||
} else if (!hasEncryptedDb && hasUnencryptedDb) {
|
||||
// 只有未加密数据库:需要迁移
|
||||
needsMigration = true;
|
||||
reason = "Unencrypted database found. Migration to encrypted format required.";
|
||||
} else {
|
||||
// 都不存在:全新安装
|
||||
needsMigration = false;
|
||||
reason = "No existing database found. This is a fresh installation.";
|
||||
}
|
||||
|
||||
return {
|
||||
needsMigration,
|
||||
hasUnencryptedDb,
|
||||
hasEncryptedDb,
|
||||
unencryptedDbSize,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建未加密数据库的安全备份
|
||||
*/
|
||||
private createBackup(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting database export for migration", {
|
||||
operation: "database_export",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
databaseLogger.info("Creating migration backup", {
|
||||
operation: "migration_backup_create",
|
||||
source: this.unencryptedDbPath,
|
||||
backup: backupPath,
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
];
|
||||
fs.copyFileSync(this.unencryptedDbPath, backupPath);
|
||||
|
||||
const exportData: MigrationExport = {
|
||||
metadata: {
|
||||
version: this.VERSION,
|
||||
exportedAt: timestamp,
|
||||
exportId,
|
||||
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(
|
||||
0,
|
||||
16,
|
||||
),
|
||||
tableCount: 0,
|
||||
recordCount: 0,
|
||||
encryptedFields: [],
|
||||
},
|
||||
data: {},
|
||||
};
|
||||
// 验证备份完整性
|
||||
const originalSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
const backupSize = fs.statSync(backupPath).size;
|
||||
|
||||
let totalRecords = 0;
|
||||
if (originalSize !== backupSize) {
|
||||
throw new Error(`Backup size mismatch: original=${originalSize}, backup=${backupSize}`);
|
||||
}
|
||||
|
||||
// Export each table
|
||||
for (const tableInfo of tablesToExport) {
|
||||
try {
|
||||
databaseLogger.debug(`Exporting table: ${tableInfo.name}`, {
|
||||
operation: "table_export",
|
||||
table: tableInfo.name,
|
||||
hasEncryption: tableInfo.hasEncryption,
|
||||
databaseLogger.success("Migration backup created successfully", {
|
||||
operation: "migration_backup_created",
|
||||
backupPath,
|
||||
fileSize: backupSize,
|
||||
});
|
||||
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create migration backup", error, {
|
||||
operation: "migration_backup_failed",
|
||||
source: this.unencryptedDbPath,
|
||||
backup: backupPath,
|
||||
});
|
||||
throw new Error(`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据库迁移的完整性
|
||||
*/
|
||||
private async verifyMigration(originalDb: Database.Database, memoryDb: Database.Database): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Verifying migration integrity", {
|
||||
operation: "migration_verify_start",
|
||||
});
|
||||
|
||||
// 临时禁用外键约束以进行验证查询
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
// 获取原数据库的表列表
|
||||
const originalTables = originalDb
|
||||
.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
.all() as { name: string }[];
|
||||
|
||||
// 获取内存数据库的表列表
|
||||
const memoryTables = memoryDb
|
||||
.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
.all() as { name: string }[];
|
||||
|
||||
// 检查表数量是否一致
|
||||
if (originalTables.length !== memoryTables.length) {
|
||||
databaseLogger.error("Table count mismatch during migration verification", null, {
|
||||
operation: "migration_verify_failed",
|
||||
originalCount: originalTables.length,
|
||||
memoryCount: memoryTables.length,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
let totalOriginalRows = 0;
|
||||
let totalMemoryRows = 0;
|
||||
|
||||
// 逐表验证行数
|
||||
for (const table of originalTables) {
|
||||
const originalCount = originalDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
||||
const memoryCount = memoryDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
||||
|
||||
totalOriginalRows += originalCount.count;
|
||||
totalMemoryRows += memoryCount.count;
|
||||
|
||||
if (originalCount.count !== memoryCount.count) {
|
||||
databaseLogger.error("Row count mismatch for table during migration verification", null, {
|
||||
operation: "migration_verify_table_failed",
|
||||
table: table.name,
|
||||
originalRows: originalCount.count,
|
||||
memoryRows: memoryCount.count,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Query all records from the table
|
||||
const records = await db.select().from(tableInfo.table);
|
||||
databaseLogger.debug("Table verification passed", {
|
||||
operation: "migration_verify_table_success",
|
||||
table: table.name,
|
||||
rows: originalCount.count,
|
||||
});
|
||||
}
|
||||
|
||||
// Decrypt encrypted fields if necessary
|
||||
let processedRecords = records;
|
||||
if (tableInfo.hasEncryption && records.length > 0) {
|
||||
processedRecords = records.map((record) => {
|
||||
try {
|
||||
return DatabaseEncryption.decryptRecord(tableInfo.name, record);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
`Failed to decrypt record in ${tableInfo.name}`,
|
||||
{
|
||||
operation: "export_decrypt_warning",
|
||||
table: tableInfo.name,
|
||||
recordId: (record as any).id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
// Return original record if decryption fails
|
||||
return record;
|
||||
databaseLogger.success("Migration integrity verification completed", {
|
||||
operation: "migration_verify_success",
|
||||
tables: originalTables.length,
|
||||
totalRows: totalOriginalRows,
|
||||
});
|
||||
|
||||
// 重新启用外键约束
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Migration verification failed", error, {
|
||||
operation: "migration_verify_error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据库迁移
|
||||
*/
|
||||
async migrateDatabase(): Promise<MigrationResult> {
|
||||
const startTime = Date.now();
|
||||
let backupPath: string | undefined;
|
||||
let migratedTables = 0;
|
||||
let migratedRows = 0;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting database migration from unencrypted to encrypted format", {
|
||||
operation: "migration_start",
|
||||
source: this.unencryptedDbPath,
|
||||
target: this.encryptedDbPath,
|
||||
});
|
||||
|
||||
// 1. 创建安全备份
|
||||
backupPath = this.createBackup();
|
||||
|
||||
// 2. 打开原数据库(只读)
|
||||
const originalDb = new Database(this.unencryptedDbPath, { readonly: true });
|
||||
|
||||
// 3. 创建内存数据库
|
||||
const memoryDb = new Database(":memory:");
|
||||
|
||||
try {
|
||||
// 4. 获取所有表结构
|
||||
const tables = originalDb
|
||||
.prepare(`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`)
|
||||
.all() as { name: string; sql: string }[];
|
||||
|
||||
databaseLogger.info("Found tables to migrate", {
|
||||
operation: "migration_tables_found",
|
||||
tableCount: tables.length,
|
||||
tables: tables.map(t => t.name),
|
||||
});
|
||||
|
||||
// 5. 在内存数据库中创建表结构
|
||||
for (const table of tables) {
|
||||
memoryDb.exec(table.sql);
|
||||
migratedTables++;
|
||||
|
||||
databaseLogger.debug("Table structure created", {
|
||||
operation: "migration_table_created",
|
||||
table: table.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 禁用外键约束以避免插入顺序问题
|
||||
databaseLogger.info("Disabling foreign key constraints for migration", {
|
||||
operation: "migration_disable_fk",
|
||||
});
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
// 7. 复制每个表的数据
|
||||
for (const table of tables) {
|
||||
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insertStmt = memoryDb.prepare(
|
||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`
|
||||
);
|
||||
|
||||
// 使用事务批量插入
|
||||
const insertTransaction = memoryDb.transaction((dataRows: any[]) => {
|
||||
for (const row of dataRows) {
|
||||
const values = columns.map((col) => row[col]);
|
||||
insertStmt.run(values);
|
||||
}
|
||||
});
|
||||
|
||||
// Track which fields were encrypted
|
||||
if (records.length > 0) {
|
||||
const sampleRecord = records[0];
|
||||
for (const fieldName of Object.keys(sampleRecord)) {
|
||||
if (
|
||||
FieldEncryption.shouldEncryptField(tableInfo.name, fieldName)
|
||||
) {
|
||||
const fieldKey = `${tableInfo.name}.${fieldName}`;
|
||||
if (!exportData.metadata.encryptedFields.includes(fieldKey)) {
|
||||
exportData.metadata.encryptedFields.push(fieldKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
insertTransaction(rows);
|
||||
migratedRows += rows.length;
|
||||
|
||||
databaseLogger.debug("Table data migrated", {
|
||||
operation: "migration_table_data",
|
||||
table: table.name,
|
||||
rows: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
exportData.data[tableInfo.name] = processedRecords;
|
||||
totalRecords += processedRecords.length;
|
||||
|
||||
databaseLogger.debug(`Table ${tableInfo.name} exported`, {
|
||||
operation: "table_export_complete",
|
||||
table: tableInfo.name,
|
||||
recordCount: processedRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to export table ${tableInfo.name}`,
|
||||
error,
|
||||
{
|
||||
operation: "table_export_failed",
|
||||
table: tableInfo.name,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
exportData.metadata.tableCount = tablesToExport.length;
|
||||
exportData.metadata.recordCount = totalRecords;
|
||||
|
||||
// Write export file
|
||||
const exportContent = JSON.stringify(exportData, null, 2);
|
||||
fs.writeFileSync(actualExportPath, exportContent, "utf8");
|
||||
|
||||
databaseLogger.success("Database export completed successfully", {
|
||||
operation: "database_export_complete",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
tableCount: exportData.metadata.tableCount,
|
||||
recordCount: exportData.metadata.recordCount,
|
||||
fileSize: exportContent.length,
|
||||
});
|
||||
|
||||
return actualExportPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database export failed", error, {
|
||||
operation: "database_export_failed",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import database from migration export
|
||||
* Re-encrypts fields for the current hardware
|
||||
*/
|
||||
static async importDatabase(
|
||||
importPath: string,
|
||||
options: {
|
||||
replaceExisting?: boolean;
|
||||
backupCurrent?: boolean;
|
||||
} = {},
|
||||
): Promise<ImportResult> {
|
||||
const { replaceExisting = false, backupCurrent = true } = options;
|
||||
|
||||
if (!fs.existsSync(importPath)) {
|
||||
throw new Error(`Import file does not exist: ${importPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting database import from migration export", {
|
||||
operation: "database_import",
|
||||
importPath,
|
||||
replaceExisting,
|
||||
backupCurrent,
|
||||
});
|
||||
|
||||
// Read and validate export file
|
||||
const exportContent = fs.readFileSync(importPath, "utf8");
|
||||
const exportData: MigrationExport = JSON.parse(exportContent);
|
||||
|
||||
// Validate export format
|
||||
if (exportData.metadata.version !== this.VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported export version: ${exportData.metadata.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
imported: { tables: 0, records: 0 },
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// Create backup if requested
|
||||
if (backupCurrent) {
|
||||
try {
|
||||
const backupPath = await this.createCurrentDatabaseBackup();
|
||||
databaseLogger.info("Current database backed up before import", {
|
||||
operation: "import_backup",
|
||||
backupPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.warnings.push(warningMsg);
|
||||
databaseLogger.warn("Failed to create pre-import backup", {
|
||||
operation: "import_backup_failed",
|
||||
error: warningMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Import data table by table
|
||||
for (const [tableName, tableData] of Object.entries(exportData.data)) {
|
||||
try {
|
||||
databaseLogger.debug(`Importing table: ${tableName}`, {
|
||||
operation: "table_import",
|
||||
table: tableName,
|
||||
recordCount: tableData.length,
|
||||
});
|
||||
|
||||
if (replaceExisting) {
|
||||
// Clear existing data
|
||||
const tableSchema = this.getTableSchema(tableName);
|
||||
if (tableSchema) {
|
||||
await db.delete(tableSchema);
|
||||
databaseLogger.debug(`Cleared existing data from ${tableName}`, {
|
||||
operation: "table_clear",
|
||||
table: tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process and encrypt records
|
||||
for (const record of tableData) {
|
||||
try {
|
||||
// Re-encrypt sensitive fields for current hardware
|
||||
const processedRecord = DatabaseEncryption.encryptRecord(
|
||||
tableName,
|
||||
record,
|
||||
);
|
||||
|
||||
// Insert record
|
||||
const tableSchema = this.getTableSchema(tableName);
|
||||
if (tableSchema) {
|
||||
await db.insert(tableSchema).values(processedRecord);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.error("Failed to import record", error, {
|
||||
operation: "record_import_failed",
|
||||
table: tableName,
|
||||
recordId: record.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.imported.tables++;
|
||||
result.imported.records += tableData.length;
|
||||
|
||||
databaseLogger.debug(`Table ${tableName} imported`, {
|
||||
operation: "table_import_complete",
|
||||
table: tableName,
|
||||
recordCount: tableData.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.error("Failed to import table", error, {
|
||||
operation: "table_import_failed",
|
||||
table: tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if import was successful
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
if (result.success) {
|
||||
databaseLogger.success("Database import completed successfully", {
|
||||
operation: "database_import_complete",
|
||||
importPath,
|
||||
tablesImported: result.imported.tables,
|
||||
recordsImported: result.imported.records,
|
||||
warnings: result.warnings.length,
|
||||
// 8. 重新启用外键约束
|
||||
databaseLogger.info("Re-enabling foreign key constraints after migration", {
|
||||
operation: "migration_enable_fk",
|
||||
});
|
||||
} 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,
|
||||
},
|
||||
);
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
// 验证外键约束现在是否正常
|
||||
const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all();
|
||||
if (fkCheckResult.length > 0) {
|
||||
databaseLogger.error("Foreign key constraints violations detected after migration", null, {
|
||||
operation: "migration_fk_check_failed",
|
||||
violations: fkCheckResult,
|
||||
});
|
||||
throw new Error(`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`);
|
||||
}
|
||||
|
||||
databaseLogger.success("Foreign key constraints verification passed", {
|
||||
operation: "migration_fk_check_success",
|
||||
});
|
||||
|
||||
// 9. 验证迁移完整性
|
||||
const verificationPassed = await this.verifyMigration(originalDb, memoryDb);
|
||||
if (!verificationPassed) {
|
||||
throw new Error("Migration integrity verification failed");
|
||||
}
|
||||
|
||||
// 10. 导出内存数据库到缓冲区
|
||||
const buffer = memoryDb.serialize();
|
||||
|
||||
// 11. 创建加密数据库文件
|
||||
databaseLogger.info("Creating encrypted database file", {
|
||||
operation: "migration_encrypt_start",
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, this.encryptedDbPath);
|
||||
|
||||
// 12. 验证加密文件
|
||||
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)) {
|
||||
throw new Error("Encrypted database file verification failed");
|
||||
}
|
||||
|
||||
// 13. 清理:重命名原文件而不是删除
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
|
||||
|
||||
fs.renameSync(this.unencryptedDbPath, migratedPath);
|
||||
|
||||
databaseLogger.success("Database migration completed successfully", {
|
||||
operation: "migration_complete",
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
duration: Date.now() - startTime,
|
||||
backupPath,
|
||||
migratedPath,
|
||||
encryptedDbPath: this.encryptedDbPath,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
backupPath,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
|
||||
} finally {
|
||||
// 确保数据库连接关闭
|
||||
originalDb.close();
|
||||
memoryDb.close();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database import failed", error, {
|
||||
operation: "database_import_failed",
|
||||
importPath,
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
databaseLogger.error("Database migration failed", error, {
|
||||
operation: "migration_failed",
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
duration: Date.now() - startTime,
|
||||
backupPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
backupPath,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate export file format and compatibility
|
||||
* 清理旧的备份文件(保留最近3个)
|
||||
*/
|
||||
static validateExportFile(exportPath: string): {
|
||||
valid: boolean;
|
||||
metadata?: ExportMetadata;
|
||||
errors: string[];
|
||||
} {
|
||||
const result = {
|
||||
valid: false,
|
||||
metadata: undefined as ExportMetadata | undefined,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
cleanupOldBackups(): void {
|
||||
try {
|
||||
if (!fs.existsSync(exportPath)) {
|
||||
result.errors.push("Export file does not exist");
|
||||
return result;
|
||||
}
|
||||
const backupPattern = /\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
const migratedPattern = /\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
|
||||
const exportContent = fs.readFileSync(exportPath, "utf8");
|
||||
const exportData: MigrationExport = JSON.parse(exportContent);
|
||||
const files = fs.readdirSync(this.dataDir);
|
||||
|
||||
// Validate structure
|
||||
if (!exportData.metadata || !exportData.data) {
|
||||
result.errors.push("Invalid export file structure");
|
||||
return result;
|
||||
}
|
||||
// 查找备份文件和已迁移文件
|
||||
const backupFiles = files.filter(f => backupPattern.test(f))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Validate version
|
||||
if (exportData.metadata.version !== this.VERSION) {
|
||||
result.errors.push(
|
||||
`Unsupported export version: ${exportData.metadata.version}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
const migratedFiles = files.filter(f => migratedPattern.test(f))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Validate required metadata fields
|
||||
const requiredFields = [
|
||||
"exportedAt",
|
||||
"exportId",
|
||||
"sourceHardwareFingerprint",
|
||||
];
|
||||
for (const field of requiredFields) {
|
||||
if (!exportData.metadata[field as keyof ExportMetadata]) {
|
||||
result.errors.push(`Missing required metadata field: ${field}`);
|
||||
// 保留最近3个备份文件
|
||||
const backupsToDelete = backupFiles.slice(3);
|
||||
const migratedToDelete = migratedFiles.slice(3);
|
||||
|
||||
for (const file of [...backupsToDelete, ...migratedToDelete]) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
databaseLogger.debug("Cleaned up old migration file", {
|
||||
operation: "migration_cleanup",
|
||||
file: file.name,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to cleanup old migration file", {
|
||||
operation: "migration_cleanup_failed",
|
||||
file: file.name,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length === 0) {
|
||||
result.valid = true;
|
||||
result.metadata = exportData.metadata;
|
||||
if (backupsToDelete.length > 0 || migratedToDelete.length > 0) {
|
||||
databaseLogger.info("Migration cleanup completed", {
|
||||
operation: "migration_cleanup_complete",
|
||||
deletedBackups: backupsToDelete.length,
|
||||
deletedMigrated: migratedToDelete.length,
|
||||
remainingBackups: Math.min(backupFiles.length, 3),
|
||||
remainingMigrated: Math.min(migratedFiles.length, 3),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.errors.push(
|
||||
`Failed to parse export file: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return result;
|
||||
databaseLogger.warn("Migration cleanup failed", {
|
||||
operation: "migration_cleanup_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup of current database
|
||||
*/
|
||||
private static async createCurrentDatabaseBackup(): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupDir = path.join(databasePaths.directory, "backups");
|
||||
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create encrypted backup
|
||||
const backupPath = DatabaseFileEncryption.createEncryptedBackup(
|
||||
databasePaths.main,
|
||||
backupDir,
|
||||
);
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table schema for database operations
|
||||
*/
|
||||
private static getTableSchema(tableName: string) {
|
||||
const tableMap: { [key: string]: any } = {
|
||||
users: users,
|
||||
ssh_data: sshData,
|
||||
ssh_credentials: sshCredentials,
|
||||
settings: settings,
|
||||
file_manager_recent: fileManagerRecent,
|
||||
file_manager_pinned: fileManagerPinned,
|
||||
file_manager_shortcuts: fileManagerShortcuts,
|
||||
dismissed_alerts: dismissedAlerts,
|
||||
ssh_credential_usage: sshCredentialUsage,
|
||||
};
|
||||
|
||||
return tableMap[tableName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export file info without importing
|
||||
*/
|
||||
static getExportInfo(exportPath: string): ExportMetadata | null {
|
||||
const validation = this.validateExportFile(exportPath);
|
||||
return validation.valid ? validation.metadata! : null;
|
||||
}
|
||||
}
|
||||
|
||||
export { DatabaseMigration };
|
||||
export type { ExportMetadata, MigrationExport, ImportResult };
|
||||
}
|
||||
162
src/backend/utils/database-save-trigger.ts
Normal file
162
src/backend/utils/database-save-trigger.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Database Save Trigger - 自动触发内存数据库保存到磁盘
|
||||
* 确保数据修改后能持久化保存
|
||||
*/
|
||||
export class DatabaseSaveTrigger {
|
||||
private static saveFunction: (() => Promise<void>) | null = null;
|
||||
private static isInitialized = false;
|
||||
private static pendingSave = false;
|
||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 初始化保存触发器
|
||||
*/
|
||||
static initialize(saveFunction: () => Promise<void>): void {
|
||||
this.saveFunction = saveFunction;
|
||||
this.isInitialized = true;
|
||||
|
||||
databaseLogger.info("Database save trigger initialized", {
|
||||
operation: "db_save_trigger_init",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发数据库保存 - 防抖处理,避免频繁保存
|
||||
*/
|
||||
static async triggerSave(reason: string = "data_modification"): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn("Database save trigger not initialized", {
|
||||
operation: "db_save_trigger_not_init",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
if (this.pendingSave) {
|
||||
databaseLogger.debug("Database save already in progress, skipping", {
|
||||
operation: "db_save_trigger_skip",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.debug("Triggering database save", {
|
||||
operation: "db_save_trigger_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction!();
|
||||
|
||||
databaseLogger.debug("Database save completed", {
|
||||
operation: "db_save_trigger_success",
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database save failed", error, {
|
||||
operation: "db_save_trigger_failed",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}, 2000); // 2秒防抖
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即保存 - 用于关键操作
|
||||
*/
|
||||
static async forceSave(reason: string = "critical_operation"): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn("Database save trigger not initialized for force save", {
|
||||
operation: "db_save_trigger_force_not_init",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除防抖定时器
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
if (this.pendingSave) {
|
||||
databaseLogger.debug("Database save already in progress, waiting", {
|
||||
operation: "db_save_trigger_force_wait",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Force saving database", {
|
||||
operation: "db_save_trigger_force_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction();
|
||||
|
||||
databaseLogger.success("Database force save completed", {
|
||||
operation: "db_save_trigger_force_success",
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
operation: "db_save_trigger_force_failed",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error; // 重新抛出错误,因为这是强制保存
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存状态
|
||||
*/
|
||||
static getStatus(): {
|
||||
initialized: boolean;
|
||||
pendingSave: boolean;
|
||||
hasPendingTimeout: boolean;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
pendingSave: this.pendingSave,
|
||||
hasPendingTimeout: this.saveTimeout !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
static cleanup(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
this.pendingSave = false;
|
||||
this.isInitialized = false;
|
||||
this.saveFunction = null;
|
||||
|
||||
databaseLogger.info("Database save trigger cleaned up", {
|
||||
operation: "db_save_trigger_cleanup",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,728 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import Database from "better-sqlite3";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { DatabaseEncryption } from "./database-encryption.js";
|
||||
import { FieldEncryption } from "./encryption.js";
|
||||
import { HardwareFingerprint } from "./hardware-fingerprint.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { databasePaths, db, sqliteInstance } from "../database/db/index.js";
|
||||
import { sshData, sshCredentials, users } from "../database/db/schema.js";
|
||||
|
||||
interface ExportMetadata {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
exportId: string;
|
||||
sourceHardwareFingerprint: string;
|
||||
tableCount: number;
|
||||
recordCount: number;
|
||||
encryptedFields: string[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
imported: {
|
||||
tables: number;
|
||||
records: number;
|
||||
};
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite database export/import utility for hardware migration
|
||||
* Exports decrypted data to a new SQLite database file for hardware transfer
|
||||
*/
|
||||
class DatabaseSQLiteExport {
|
||||
private static readonly VERSION = "v1";
|
||||
private static readonly EXPORT_FILE_EXTENSION = ".termix-export.sqlite";
|
||||
private static readonly METADATA_TABLE = "_termix_export_metadata";
|
||||
|
||||
/**
|
||||
* Export database as SQLite file for migration
|
||||
* Creates a new SQLite database with decrypted data
|
||||
*/
|
||||
static async exportDatabase(exportPath?: string): Promise<string> {
|
||||
const exportId = crypto.randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const defaultExportPath = path.join(
|
||||
databasePaths.directory,
|
||||
`termix-export-${timestamp.replace(/[:.]/g, "-")}${this.EXPORT_FILE_EXTENSION}`,
|
||||
);
|
||||
const actualExportPath = exportPath || defaultExportPath;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting SQLite database export for migration", {
|
||||
operation: "database_sqlite_export",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
});
|
||||
|
||||
// Create new SQLite database for export
|
||||
const exportDb = new Database(actualExportPath);
|
||||
|
||||
// Define tables to export - only SSH-related data
|
||||
const tablesToExport = [
|
||||
{ name: "ssh_data", hasEncryption: true },
|
||||
{ name: "ssh_credentials", hasEncryption: true },
|
||||
];
|
||||
|
||||
const exportMetadata: ExportMetadata = {
|
||||
version: this.VERSION,
|
||||
exportedAt: timestamp,
|
||||
exportId,
|
||||
sourceHardwareFingerprint: HardwareFingerprint.generate().substring(
|
||||
0,
|
||||
16,
|
||||
),
|
||||
tableCount: 0,
|
||||
recordCount: 0,
|
||||
encryptedFields: [],
|
||||
};
|
||||
|
||||
let totalRecords = 0;
|
||||
|
||||
// Check total records in SSH tables for debugging
|
||||
const totalSshData = await db.select().from(sshData);
|
||||
const totalSshCredentials = await db.select().from(sshCredentials);
|
||||
|
||||
databaseLogger.info(`Export preparation: found SSH data`, {
|
||||
operation: "export_data_check",
|
||||
totalSshData: totalSshData.length,
|
||||
totalSshCredentials: totalSshCredentials.length,
|
||||
});
|
||||
|
||||
// Create metadata table
|
||||
exportDb.exec(`
|
||||
CREATE TABLE ${this.METADATA_TABLE} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Copy schema and data for each table
|
||||
for (const tableInfo of tablesToExport) {
|
||||
try {
|
||||
databaseLogger.debug(`Exporting SQLite table: ${tableInfo.name}`, {
|
||||
operation: "table_sqlite_export",
|
||||
table: tableInfo.name,
|
||||
hasEncryption: tableInfo.hasEncryption,
|
||||
});
|
||||
|
||||
// Create table in export database using consistent schema
|
||||
if (tableInfo.name === "ssh_data") {
|
||||
// Create ssh_data table using exact schema matching Drizzle definition
|
||||
const createTableSql = `CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
require_password INTEGER NOT NULL DEFAULT 1,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
credential_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`;
|
||||
exportDb.exec(createTableSql);
|
||||
} else if (tableInfo.name === "ssh_credentials") {
|
||||
// Create ssh_credentials table using exact schema matching Drizzle definition
|
||||
const createTableSql = `CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key_content TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`;
|
||||
exportDb.exec(createTableSql);
|
||||
} else {
|
||||
databaseLogger.warn(`Unknown table ${tableInfo.name}, skipping`, {
|
||||
operation: "table_sqlite_export_skip",
|
||||
table: tableInfo.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Query all records from tables using Drizzle
|
||||
let records: any[];
|
||||
if (tableInfo.name === "ssh_data") {
|
||||
records = await db.select().from(sshData);
|
||||
} else if (tableInfo.name === "ssh_credentials") {
|
||||
records = await db.select().from(sshCredentials);
|
||||
} else {
|
||||
records = [];
|
||||
}
|
||||
|
||||
databaseLogger.info(
|
||||
`Found ${records.length} records in ${tableInfo.name} for export`,
|
||||
{
|
||||
operation: "table_record_count",
|
||||
table: tableInfo.name,
|
||||
recordCount: records.length,
|
||||
},
|
||||
);
|
||||
|
||||
// Decrypt encrypted fields if necessary
|
||||
let processedRecords = records;
|
||||
if (tableInfo.hasEncryption && records.length > 0) {
|
||||
processedRecords = records.map((record) => {
|
||||
try {
|
||||
return DatabaseEncryption.decryptRecord(tableInfo.name, record);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
`Failed to decrypt record in ${tableInfo.name}`,
|
||||
{
|
||||
operation: "export_decrypt_warning",
|
||||
table: tableInfo.name,
|
||||
recordId: (record as any).id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
return record;
|
||||
}
|
||||
});
|
||||
|
||||
// Track encrypted fields
|
||||
const sampleRecord = records[0];
|
||||
for (const fieldName of Object.keys(sampleRecord)) {
|
||||
if (this.shouldTrackEncryptedField(tableInfo.name, fieldName)) {
|
||||
const fieldKey = `${tableInfo.name}.${fieldName}`;
|
||||
if (!exportMetadata.encryptedFields.includes(fieldKey)) {
|
||||
exportMetadata.encryptedFields.push(fieldKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert records into export database
|
||||
if (processedRecords.length > 0) {
|
||||
const sampleRecord = processedRecords[0];
|
||||
const tsFieldNames = Object.keys(sampleRecord);
|
||||
|
||||
// Map TypeScript field names to database column names
|
||||
const dbColumnNames = tsFieldNames.map((fieldName) => {
|
||||
// Map TypeScript field names to database column names
|
||||
const fieldMappings: Record<string, string> = {
|
||||
userId: "user_id",
|
||||
authType: "auth_type",
|
||||
requirePassword: "require_password",
|
||||
keyPassword: "key_password",
|
||||
keyType: "key_type",
|
||||
credentialId: "credential_id",
|
||||
enableTerminal: "enable_terminal",
|
||||
enableTunnel: "enable_tunnel",
|
||||
tunnelConnections: "tunnel_connections",
|
||||
enableFileManager: "enable_file_manager",
|
||||
defaultPath: "default_path",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
keyContent: "key_content",
|
||||
};
|
||||
return fieldMappings[fieldName] || fieldName;
|
||||
});
|
||||
|
||||
const placeholders = dbColumnNames.map(() => "?").join(", ");
|
||||
const insertSql = `INSERT INTO ${tableInfo.name} (${dbColumnNames.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
const insertStmt = exportDb.prepare(insertSql);
|
||||
|
||||
for (const record of processedRecords) {
|
||||
const values = tsFieldNames.map((fieldName) => {
|
||||
const value: any = record[fieldName as keyof typeof record];
|
||||
// Convert values to SQLite-compatible types
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
// Convert objects and arrays to JSON strings
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
// Fallback: convert to string
|
||||
return String(value);
|
||||
});
|
||||
insertStmt.run(values);
|
||||
}
|
||||
}
|
||||
|
||||
totalRecords += processedRecords.length;
|
||||
|
||||
databaseLogger.debug(`SQLite table ${tableInfo.name} exported`, {
|
||||
operation: "table_sqlite_export_complete",
|
||||
table: tableInfo.name,
|
||||
recordCount: processedRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to export SQLite table ${tableInfo.name}`,
|
||||
error,
|
||||
{
|
||||
operation: "table_sqlite_export_failed",
|
||||
table: tableInfo.name,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update and store metadata
|
||||
exportMetadata.tableCount = tablesToExport.length;
|
||||
exportMetadata.recordCount = totalRecords;
|
||||
|
||||
const insertMetadata = exportDb.prepare(
|
||||
`INSERT INTO ${this.METADATA_TABLE} (key, value) VALUES (?, ?)`,
|
||||
);
|
||||
insertMetadata.run("metadata", JSON.stringify(exportMetadata));
|
||||
|
||||
// Close export database
|
||||
exportDb.close();
|
||||
|
||||
databaseLogger.success("SQLite database export completed successfully", {
|
||||
operation: "database_sqlite_export_complete",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
tableCount: exportMetadata.tableCount,
|
||||
recordCount: exportMetadata.recordCount,
|
||||
fileSize: fs.statSync(actualExportPath).size,
|
||||
});
|
||||
|
||||
return actualExportPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("SQLite database export failed", error, {
|
||||
operation: "database_sqlite_export_failed",
|
||||
exportId,
|
||||
exportPath: actualExportPath,
|
||||
});
|
||||
throw new Error(
|
||||
`SQLite database export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import database from SQLite export
|
||||
* Re-encrypts fields for the current hardware
|
||||
*/
|
||||
static async importDatabase(
|
||||
importPath: string,
|
||||
options: {
|
||||
replaceExisting?: boolean;
|
||||
backupCurrent?: boolean;
|
||||
} = {},
|
||||
): Promise<ImportResult> {
|
||||
const { replaceExisting = false, backupCurrent = true } = options;
|
||||
|
||||
if (!fs.existsSync(importPath)) {
|
||||
throw new Error(`Import file does not exist: ${importPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting SQLite database import from export", {
|
||||
operation: "database_sqlite_import",
|
||||
importPath,
|
||||
replaceExisting,
|
||||
backupCurrent,
|
||||
});
|
||||
|
||||
// Open import database
|
||||
const importDb = new Database(importPath, { readonly: true });
|
||||
|
||||
// Validate export format
|
||||
const metadataResult = importDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
|
||||
`,
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
if (!metadataResult) {
|
||||
throw new Error("Invalid export file: missing metadata");
|
||||
}
|
||||
|
||||
const metadata: ExportMetadata = JSON.parse(metadataResult.value);
|
||||
if (metadata.version !== this.VERSION) {
|
||||
throw new Error(`Unsupported export version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
imported: { tables: 0, records: 0 },
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// Get current admin user to assign imported SSH records
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.is_admin, true))
|
||||
.limit(1);
|
||||
if (adminUser.length === 0) {
|
||||
throw new Error("No admin user found in current database");
|
||||
}
|
||||
const currentAdminUserId = adminUser[0].id;
|
||||
|
||||
databaseLogger.debug(
|
||||
`Starting SSH data import - assigning to admin user ${currentAdminUserId}`,
|
||||
{
|
||||
operation: "ssh_data_import_start",
|
||||
adminUserId: currentAdminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
// Create backup if requested
|
||||
if (backupCurrent) {
|
||||
try {
|
||||
const backupPath = await this.createCurrentDatabaseBackup();
|
||||
databaseLogger.info("Current database backed up before import", {
|
||||
operation: "import_backup",
|
||||
backupPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.warnings.push(warningMsg);
|
||||
databaseLogger.warn("Failed to create pre-import backup", {
|
||||
operation: "import_backup_failed",
|
||||
error: warningMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of tables to import (excluding metadata table)
|
||||
const tables = importDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name != '${this.METADATA_TABLE}'
|
||||
`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
// Import data table by table
|
||||
for (const tableRow of tables) {
|
||||
const tableName = tableRow.name;
|
||||
|
||||
try {
|
||||
databaseLogger.debug(`Importing SQLite table: ${tableName}`, {
|
||||
operation: "table_sqlite_import",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
// Use additive import - don't clear existing data
|
||||
// This preserves all current data including admin SSH connections
|
||||
databaseLogger.debug(`Using additive import for ${tableName}`, {
|
||||
operation: "table_additive_import",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
// Get all records from import table
|
||||
const records = importDb.prepare(`SELECT * FROM ${tableName}`).all();
|
||||
|
||||
// Process and encrypt records
|
||||
for (const record of records) {
|
||||
try {
|
||||
// Import all SSH data without user filtering
|
||||
|
||||
// Map database column names to TypeScript field names
|
||||
const mappedRecord: any = {};
|
||||
const columnToFieldMappings: Record<string, string> = {
|
||||
user_id: "userId",
|
||||
auth_type: "authType",
|
||||
require_password: "requirePassword",
|
||||
key_password: "keyPassword",
|
||||
key_type: "keyType",
|
||||
credential_id: "credentialId",
|
||||
enable_terminal: "enableTerminal",
|
||||
enable_tunnel: "enableTunnel",
|
||||
tunnel_connections: "tunnelConnections",
|
||||
enable_file_manager: "enableFileManager",
|
||||
default_path: "defaultPath",
|
||||
created_at: "createdAt",
|
||||
updated_at: "updatedAt",
|
||||
key_content: "keyContent",
|
||||
};
|
||||
|
||||
// Convert database column names to TypeScript field names
|
||||
for (const [dbColumn, value] of Object.entries(record)) {
|
||||
const tsField = columnToFieldMappings[dbColumn] || dbColumn;
|
||||
mappedRecord[tsField] = value;
|
||||
}
|
||||
|
||||
// Assign imported SSH records to current admin user to avoid foreign key constraint
|
||||
if (tableName === "ssh_data" && mappedRecord.userId) {
|
||||
const originalUserId = mappedRecord.userId;
|
||||
mappedRecord.userId = currentAdminUserId;
|
||||
databaseLogger.debug(
|
||||
`Reassigned SSH record from user ${originalUserId} to admin ${currentAdminUserId}`,
|
||||
{
|
||||
operation: "user_reassignment",
|
||||
originalUserId,
|
||||
newUserId: currentAdminUserId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Re-encrypt sensitive fields for current hardware
|
||||
const processedRecord = DatabaseEncryption.encryptRecord(
|
||||
tableName,
|
||||
mappedRecord,
|
||||
);
|
||||
|
||||
// Insert record using Drizzle
|
||||
try {
|
||||
if (tableName === "ssh_data") {
|
||||
await db
|
||||
.insert(sshData)
|
||||
.values(processedRecord)
|
||||
.onConflictDoNothing();
|
||||
} else if (tableName === "ssh_credentials") {
|
||||
await db
|
||||
.insert(sshCredentials)
|
||||
.values(processedRecord)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle any SQL errors gracefully
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("UNIQUE constraint failed")
|
||||
) {
|
||||
databaseLogger.debug(
|
||||
`Skipping duplicate record in ${tableName}`,
|
||||
{
|
||||
operation: "duplicate_record_skip",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.error("Failed to import record", error, {
|
||||
operation: "record_sqlite_import_failed",
|
||||
table: tableName,
|
||||
recordId: (record as any).id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.imported.tables++;
|
||||
result.imported.records += records.length;
|
||||
|
||||
databaseLogger.debug(`SQLite table ${tableName} imported`, {
|
||||
operation: "table_sqlite_import_complete",
|
||||
table: tableName,
|
||||
recordCount: records.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.error("Failed to import SQLite table", error, {
|
||||
operation: "table_sqlite_import_failed",
|
||||
table: tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close import database
|
||||
importDb.close();
|
||||
|
||||
// Check if import was successful
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
if (result.success) {
|
||||
databaseLogger.success(
|
||||
"SQLite database import completed successfully",
|
||||
{
|
||||
operation: "database_sqlite_import_complete",
|
||||
importPath,
|
||||
tablesImported: result.imported.tables,
|
||||
recordsImported: result.imported.records,
|
||||
warnings: result.warnings.length,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
databaseLogger.error(
|
||||
"SQLite database import completed with errors",
|
||||
undefined,
|
||||
{
|
||||
operation: "database_sqlite_import_partial",
|
||||
importPath,
|
||||
tablesImported: result.imported.tables,
|
||||
recordsImported: result.imported.records,
|
||||
errorCount: result.errors.length,
|
||||
warningCount: result.warnings.length,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("SQLite database import failed", error, {
|
||||
operation: "database_sqlite_import_failed",
|
||||
importPath,
|
||||
});
|
||||
throw new Error(
|
||||
`SQLite database import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SQLite export file
|
||||
*/
|
||||
static validateExportFile(exportPath: string): {
|
||||
valid: boolean;
|
||||
metadata?: ExportMetadata;
|
||||
errors: string[];
|
||||
} {
|
||||
const result = {
|
||||
valid: false,
|
||||
metadata: undefined as ExportMetadata | undefined,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(exportPath)) {
|
||||
result.errors.push("Export file does not exist");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!exportPath.endsWith(this.EXPORT_FILE_EXTENSION)) {
|
||||
result.errors.push("Invalid export file extension");
|
||||
return result;
|
||||
}
|
||||
|
||||
const exportDb = new Database(exportPath, { readonly: true });
|
||||
|
||||
try {
|
||||
const metadataResult = exportDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata'
|
||||
`,
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
if (!metadataResult) {
|
||||
result.errors.push("Missing export metadata");
|
||||
return result;
|
||||
}
|
||||
|
||||
const metadata: ExportMetadata = JSON.parse(metadataResult.value);
|
||||
|
||||
if (metadata.version !== this.VERSION) {
|
||||
result.errors.push(`Unsupported export version: ${metadata.version}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.valid = true;
|
||||
result.metadata = metadata;
|
||||
} finally {
|
||||
exportDb.close();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.errors.push(
|
||||
`Failed to validate export file: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export file info without importing
|
||||
*/
|
||||
static getExportInfo(exportPath: string): ExportMetadata | null {
|
||||
const validation = this.validateExportFile(exportPath);
|
||||
return validation.valid ? validation.metadata! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup of current database
|
||||
*/
|
||||
private static async createCurrentDatabaseBackup(): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupDir = path.join(databasePaths.directory, "backups");
|
||||
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create SQLite backup
|
||||
const backupPath = path.join(
|
||||
backupDir,
|
||||
`database-backup-${timestamp}.sqlite`,
|
||||
);
|
||||
|
||||
// Copy current database file
|
||||
fs.copyFileSync(databasePaths.main, backupPath);
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table schema for database operations
|
||||
* NOTE: This method is deprecated - we now use raw SQL to avoid FK issues
|
||||
*/
|
||||
private static getTableSchema(tableName: string) {
|
||||
return null; // No longer used
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field should be tracked as encrypted
|
||||
*/
|
||||
private static shouldTrackEncryptedField(
|
||||
tableName: string,
|
||||
fieldName: string,
|
||||
): boolean {
|
||||
try {
|
||||
return FieldEncryption.shouldEncryptField(tableName, fieldName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DatabaseSQLiteExport };
|
||||
export type { ExportMetadata, ImportResult };
|
||||
@@ -1,242 +0,0 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import { DatabaseEncryption } from "./database-encryption.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
|
||||
class EncryptedDBOperations {
|
||||
static async insert<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
|
||||
const result = await db.insert(table).values(encryptedData).returning();
|
||||
|
||||
// Decrypt the returned data to ensure consistency
|
||||
const decryptedResult = DatabaseEncryption.decryptRecord(
|
||||
tableName,
|
||||
result[0],
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
|
||||
operation: "encrypted_insert",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
return decryptedResult as T;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to insert encrypted record into ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "encrypted_insert_failed",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async select<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const results = await query;
|
||||
const decryptedResults = DatabaseEncryption.decryptRecords(
|
||||
tableName,
|
||||
results,
|
||||
);
|
||||
|
||||
return decryptedResults;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to select/decrypt records from ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "encrypted_select_failed",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async selectOne<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
const decryptedResult = DatabaseEncryption.decryptRecord(
|
||||
tableName,
|
||||
result,
|
||||
);
|
||||
|
||||
return decryptedResult;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to select/decrypt single record from ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "encrypted_select_one_failed",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async update<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
data: Partial<T>,
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const encryptedData = DatabaseEncryption.encryptRecord(tableName, data);
|
||||
const result = await db
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
.where(where)
|
||||
.returning();
|
||||
|
||||
databaseLogger.debug(`Updated encrypted record in ${tableName}`, {
|
||||
operation: "encrypted_update",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
return result as T[];
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update encrypted record in ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "encrypted_update_failed",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async delete(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const result = await db.delete(table).where(where).returning();
|
||||
|
||||
databaseLogger.debug(`Deleted record from ${tableName}`, {
|
||||
operation: "encrypted_delete",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to delete record from ${tableName}`, error, {
|
||||
operation: "encrypted_delete_failed",
|
||||
table: tableName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async migrateExistingRecords(tableName: TableName): Promise<number> {
|
||||
let migratedCount = 0;
|
||||
|
||||
try {
|
||||
databaseLogger.info(`Starting encryption migration for ${tableName}`, {
|
||||
operation: "migration_start",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
let table: SQLiteTable<any>;
|
||||
let records: any[];
|
||||
|
||||
switch (tableName) {
|
||||
case "users":
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
table = users;
|
||||
records = await db.select().from(users);
|
||||
break;
|
||||
case "ssh_data":
|
||||
const { sshData } = await import("../database/db/schema.js");
|
||||
table = sshData;
|
||||
records = await db.select().from(sshData);
|
||||
break;
|
||||
case "ssh_credentials":
|
||||
const { sshCredentials } = await import("../database/db/schema.js");
|
||||
table = sshCredentials;
|
||||
records = await db.select().from(sshCredentials);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown table: ${tableName}`);
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
const migratedRecord = await DatabaseEncryption.migrateRecord(
|
||||
tableName,
|
||||
record,
|
||||
);
|
||||
|
||||
if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) {
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await db
|
||||
.update(table)
|
||||
.set(migratedRecord)
|
||||
.where(eq((table as any).id, record.id));
|
||||
migratedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to migrate record ${record.id} in ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "migration_record_failed",
|
||||
table: tableName,
|
||||
recordId: record.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success(`Migration completed for ${tableName}`, {
|
||||
operation: "migration_complete",
|
||||
table: tableName,
|
||||
migratedCount,
|
||||
totalRecords: records.length,
|
||||
});
|
||||
|
||||
return migratedCount;
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Migration failed for ${tableName}`, error, {
|
||||
operation: "migration_failed",
|
||||
table: tableName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const status = DatabaseEncryption.getEncryptionStatus();
|
||||
return status.configValid && status.enabled;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Encryption health check failed", error, {
|
||||
operation: "health_check_failed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { EncryptedDBOperations };
|
||||
export type { TableName };
|
||||
@@ -1,353 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { settings } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { MasterKeyProtection } from "./master-key-protection.js";
|
||||
|
||||
interface EncryptionKeyInfo {
|
||||
hasKey: boolean;
|
||||
keyId?: string;
|
||||
createdAt?: string;
|
||||
algorithm: string;
|
||||
}
|
||||
|
||||
class EncryptionKeyManager {
|
||||
private static instance: EncryptionKeyManager;
|
||||
private currentKey: string | null = null;
|
||||
private keyInfo: EncryptionKeyInfo | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): EncryptionKeyManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new EncryptionKeyManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private encodeKey(key: string): string {
|
||||
return MasterKeyProtection.encryptMasterKey(key);
|
||||
}
|
||||
|
||||
private decodeKey(encodedKey: string): string {
|
||||
if (MasterKeyProtection.isProtectedKey(encodedKey)) {
|
||||
return MasterKeyProtection.decryptMasterKey(encodedKey);
|
||||
}
|
||||
|
||||
databaseLogger.warn(
|
||||
"Found legacy base64-encoded key, migrating to KEK protection",
|
||||
{
|
||||
operation: "key_migration_legacy",
|
||||
},
|
||||
);
|
||||
const buffer = Buffer.from(encodedKey, "base64");
|
||||
return buffer.toString("hex");
|
||||
}
|
||||
|
||||
async initializeKey(): Promise<string> {
|
||||
try {
|
||||
let existingKey = await this.getStoredKey();
|
||||
|
||||
if (existingKey) {
|
||||
databaseLogger.success("Found existing encryption key", {
|
||||
operation: "key_init",
|
||||
hasKey: true,
|
||||
});
|
||||
this.currentKey = existingKey;
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
const environmentKey = process.env.DB_ENCRYPTION_KEY;
|
||||
if (environmentKey && environmentKey !== "default-key-change-me") {
|
||||
if (!this.validateKeyStrength(environmentKey)) {
|
||||
databaseLogger.error(
|
||||
"Environment encryption key is too weak",
|
||||
undefined,
|
||||
{
|
||||
operation: "key_init",
|
||||
source: "environment",
|
||||
keyLength: environmentKey.length,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
"DB_ENCRYPTION_KEY is too weak. Must be at least 32 characters with good entropy.",
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Using encryption key from environment variable", {
|
||||
operation: "key_init",
|
||||
source: "environment",
|
||||
});
|
||||
|
||||
await this.storeKey(environmentKey);
|
||||
this.currentKey = environmentKey;
|
||||
return environmentKey;
|
||||
}
|
||||
|
||||
const newKey = await this.generateNewKey();
|
||||
databaseLogger.warn(
|
||||
"Generated new encryption key - PLEASE BACKUP THIS KEY",
|
||||
{
|
||||
operation: "key_init",
|
||||
generated: true,
|
||||
keyPreview: newKey.substring(0, 8) + "...",
|
||||
},
|
||||
);
|
||||
|
||||
return newKey;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize encryption key", error, {
|
||||
operation: "key_init_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateNewKey(): Promise<string> {
|
||||
const newKey = crypto.randomBytes(32).toString("hex");
|
||||
const keyId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
await this.storeKey(newKey, keyId);
|
||||
this.currentKey = newKey;
|
||||
|
||||
databaseLogger.success("Generated new encryption key", {
|
||||
operation: "key_generated",
|
||||
keyId,
|
||||
keyLength: newKey.length,
|
||||
});
|
||||
|
||||
return newKey;
|
||||
}
|
||||
|
||||
private async storeKey(key: string, keyId?: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const id = keyId || crypto.randomBytes(8).toString("hex");
|
||||
|
||||
const keyData = {
|
||||
key: this.encodeKey(key),
|
||||
keyId: id,
|
||||
createdAt: now,
|
||||
algorithm: "aes-256-gcm",
|
||||
};
|
||||
|
||||
const encodedData = JSON.stringify(keyData);
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "db_encryption_key"));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(settings)
|
||||
.set({ value: encodedData })
|
||||
.where(eq(settings.key, "db_encryption_key"));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: "db_encryption_key",
|
||||
value: encodedData,
|
||||
});
|
||||
}
|
||||
|
||||
const existingCreated = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "encryption_key_created"));
|
||||
|
||||
if (existingCreated.length > 0) {
|
||||
await db
|
||||
.update(settings)
|
||||
.set({ value: now })
|
||||
.where(eq(settings.key, "encryption_key_created"));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: "encryption_key_created",
|
||||
value: now,
|
||||
});
|
||||
}
|
||||
|
||||
this.keyInfo = {
|
||||
hasKey: true,
|
||||
keyId: id,
|
||||
createdAt: now,
|
||||
algorithm: "aes-256-gcm",
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to store encryption key", error, {
|
||||
operation: "key_store_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getStoredKey(): Promise<string | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "db_encryption_key"));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedData = result[0].value;
|
||||
let keyData;
|
||||
|
||||
try {
|
||||
keyData = JSON.parse(encodedData);
|
||||
} catch {
|
||||
databaseLogger.warn("Found legacy base64-encoded key data, migrating", {
|
||||
operation: "key_data_migration_legacy",
|
||||
});
|
||||
keyData = JSON.parse(Buffer.from(encodedData, "base64").toString());
|
||||
}
|
||||
|
||||
this.keyInfo = {
|
||||
hasKey: true,
|
||||
keyId: keyData.keyId,
|
||||
createdAt: keyData.createdAt,
|
||||
algorithm: keyData.algorithm,
|
||||
};
|
||||
|
||||
const decodedKey = this.decodeKey(keyData.key);
|
||||
|
||||
if (!MasterKeyProtection.isProtectedKey(keyData.key)) {
|
||||
databaseLogger.info("Auto-migrating legacy key to KEK protection", {
|
||||
operation: "key_auto_migration",
|
||||
keyId: keyData.keyId,
|
||||
});
|
||||
await this.storeKey(decodedKey, keyData.keyId);
|
||||
}
|
||||
|
||||
return decodedKey;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to retrieve stored encryption key", error, {
|
||||
operation: "key_retrieve_failed",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentKey(): string | null {
|
||||
return this.currentKey;
|
||||
}
|
||||
|
||||
async getKeyInfo(): Promise<EncryptionKeyInfo> {
|
||||
if (!this.keyInfo) {
|
||||
const hasKey = (await this.getStoredKey()) !== null;
|
||||
return {
|
||||
hasKey,
|
||||
algorithm: "aes-256-gcm",
|
||||
};
|
||||
}
|
||||
return this.keyInfo;
|
||||
}
|
||||
|
||||
async regenerateKey(): Promise<string> {
|
||||
databaseLogger.info("Regenerating encryption key", {
|
||||
operation: "key_regenerate",
|
||||
});
|
||||
|
||||
const oldKeyInfo = await this.getKeyInfo();
|
||||
const newKey = await this.generateNewKey();
|
||||
|
||||
databaseLogger.warn(
|
||||
"Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED",
|
||||
{
|
||||
operation: "key_regenerated",
|
||||
oldKeyId: oldKeyInfo.keyId,
|
||||
newKeyId: this.keyInfo?.keyId,
|
||||
},
|
||||
);
|
||||
|
||||
return newKey;
|
||||
}
|
||||
|
||||
private validateKeyStrength(key: string): boolean {
|
||||
if (key.length < 32) return false;
|
||||
|
||||
const hasLower = /[a-z]/.test(key);
|
||||
const hasUpper = /[A-Z]/.test(key);
|
||||
const hasDigit = /\d/.test(key);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(key);
|
||||
|
||||
const entropyTest = new Set(key).size / key.length;
|
||||
|
||||
const complexity =
|
||||
Number(hasLower) +
|
||||
Number(hasUpper) +
|
||||
Number(hasDigit) +
|
||||
Number(hasSpecial);
|
||||
return complexity >= 3 && entropyTest > 0.4;
|
||||
}
|
||||
|
||||
async validateKey(key?: string): Promise<boolean> {
|
||||
const testKey = key || this.currentKey;
|
||||
if (!testKey) return false;
|
||||
|
||||
try {
|
||||
const testData = "validation-test-" + Date.now();
|
||||
const testBuffer = Buffer.from(testKey, "hex");
|
||||
|
||||
if (testBuffer.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-gcm",
|
||||
testBuffer,
|
||||
iv,
|
||||
) as any;
|
||||
cipher.update(testData, "utf8");
|
||||
cipher.final();
|
||||
cipher.getAuthTag();
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.currentKey !== null;
|
||||
}
|
||||
|
||||
async getEncryptionStatus() {
|
||||
const keyInfo = await this.getKeyInfo();
|
||||
const isValid = await this.validateKey();
|
||||
const kekProtected = await this.isKEKProtected();
|
||||
|
||||
return {
|
||||
hasKey: keyInfo.hasKey,
|
||||
keyValid: isValid,
|
||||
keyId: keyInfo.keyId,
|
||||
createdAt: keyInfo.createdAt,
|
||||
algorithm: keyInfo.algorithm,
|
||||
initialized: this.isInitialized(),
|
||||
kekProtected,
|
||||
kekValid: kekProtected ? MasterKeyProtection.validateProtection() : false,
|
||||
};
|
||||
}
|
||||
|
||||
private async isKEKProtected(): Promise<boolean> {
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "db_encryption_key"));
|
||||
if (result.length === 0) return false;
|
||||
|
||||
const keyData = JSON.parse(result[0].value);
|
||||
return MasterKeyProtection.isProtectedKey(keyData.key);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { EncryptionKeyManager };
|
||||
export type { EncryptionKeyInfo };
|
||||
@@ -1,435 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { DatabaseEncryption } from "./database-encryption.js";
|
||||
import { EncryptedDBOperations } from "./encrypted-db-operations.js";
|
||||
import { EncryptionKeyManager } from "./encryption-key-manager.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { settings } from "../database/db/schema.js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
interface MigrationConfig {
|
||||
masterPassword?: string;
|
||||
forceEncryption?: boolean;
|
||||
backupEnabled?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
class EncryptionMigration {
|
||||
private config: MigrationConfig;
|
||||
|
||||
constructor(config: MigrationConfig = {}) {
|
||||
this.config = {
|
||||
masterPassword: config.masterPassword,
|
||||
forceEncryption: config.forceEncryption ?? false,
|
||||
backupEnabled: config.backupEnabled ?? true,
|
||||
dryRun: config.dryRun ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async runMigration(): Promise<void> {
|
||||
databaseLogger.info("Starting database encryption migration", {
|
||||
operation: "migration_start",
|
||||
dryRun: this.config.dryRun,
|
||||
forceEncryption: this.config.forceEncryption,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.validatePrerequisites();
|
||||
|
||||
if (this.config.backupEnabled && !this.config.dryRun) {
|
||||
await this.createBackup();
|
||||
}
|
||||
|
||||
await this.initializeEncryption();
|
||||
await this.migrateTables();
|
||||
await this.updateSettings();
|
||||
await this.verifyMigration();
|
||||
|
||||
databaseLogger.success(
|
||||
"Database encryption migration completed successfully",
|
||||
{
|
||||
operation: "migration_complete",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Migration failed", error, {
|
||||
operation: "migration_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async validatePrerequisites(): Promise<void> {
|
||||
databaseLogger.info("Validating migration prerequisites", {
|
||||
operation: "validation",
|
||||
});
|
||||
|
||||
// Check if KEK-managed encryption key exists
|
||||
const keyManager = EncryptionKeyManager.getInstance();
|
||||
|
||||
if (!this.config.masterPassword) {
|
||||
// Try to get current key from KEK manager
|
||||
try {
|
||||
const currentKey = keyManager.getCurrentKey();
|
||||
if (!currentKey) {
|
||||
// Initialize key if not available
|
||||
const initializedKey = await keyManager.initializeKey();
|
||||
this.config.masterPassword = initializedKey;
|
||||
} else {
|
||||
this.config.masterPassword = currentKey;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate key strength
|
||||
if (this.config.masterPassword.length < 16) {
|
||||
throw new Error("Master password must be at least 16 characters long");
|
||||
}
|
||||
|
||||
// Test database connection
|
||||
try {
|
||||
await db.select().from(settings).limit(1);
|
||||
} catch (error) {
|
||||
throw new Error("Database connection failed");
|
||||
}
|
||||
|
||||
databaseLogger.success("Prerequisites validation passed", {
|
||||
operation: "validation_complete",
|
||||
keySource: "kek_manager",
|
||||
});
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<void> {
|
||||
databaseLogger.info("Creating database backup before migration", {
|
||||
operation: "backup_start",
|
||||
});
|
||||
|
||||
try {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const backupPath = path.join(dataDir, `db-backup-${Date.now()}.sqlite`);
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
databaseLogger.success(`Database backup created: ${backupPath}`, {
|
||||
operation: "backup_complete",
|
||||
backupPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create backup", error, {
|
||||
operation: "backup_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeEncryption(): Promise<void> {
|
||||
databaseLogger.info("Initializing encryption system", {
|
||||
operation: "encryption_init",
|
||||
});
|
||||
|
||||
DatabaseEncryption.initialize({
|
||||
masterPassword: this.config.masterPassword!,
|
||||
encryptionEnabled: true,
|
||||
forceEncryption: this.config.forceEncryption,
|
||||
migrateOnAccess: true,
|
||||
});
|
||||
|
||||
const isHealthy = await EncryptedDBOperations.healthCheck();
|
||||
if (!isHealthy) {
|
||||
throw new Error("Encryption system health check failed");
|
||||
}
|
||||
|
||||
databaseLogger.success("Encryption system initialized successfully", {
|
||||
operation: "encryption_init_complete",
|
||||
});
|
||||
}
|
||||
|
||||
private async migrateTables(): Promise<void> {
|
||||
const tables: Array<"users" | "ssh_data" | "ssh_credentials"> = [
|
||||
"users",
|
||||
"ssh_data",
|
||||
"ssh_credentials",
|
||||
];
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const tableName of tables) {
|
||||
databaseLogger.info(`Starting migration for table: ${tableName}`, {
|
||||
operation: "table_migration_start",
|
||||
table: tableName,
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.config.dryRun) {
|
||||
databaseLogger.info(`[DRY RUN] Would migrate table: ${tableName}`, {
|
||||
operation: "dry_run_table",
|
||||
table: tableName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const migratedCount =
|
||||
await EncryptedDBOperations.migrateExistingRecords(tableName);
|
||||
totalMigrated += migratedCount;
|
||||
|
||||
databaseLogger.success(`Migration completed for table: ${tableName}`, {
|
||||
operation: "table_migration_complete",
|
||||
table: tableName,
|
||||
migratedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Migration failed for table: ${tableName}`,
|
||||
error,
|
||||
{
|
||||
operation: "table_migration_failed",
|
||||
table: tableName,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success(`All tables migrated successfully`, {
|
||||
operation: "all_tables_migrated",
|
||||
totalMigrated,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateSettings(): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
databaseLogger.info("[DRY RUN] Would update encryption settings", {
|
||||
operation: "dry_run_settings",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptionSettings = [
|
||||
{ key: "encryption_enabled", value: "true" },
|
||||
{
|
||||
key: "encryption_migration_completed",
|
||||
value: new Date().toISOString(),
|
||||
},
|
||||
{ key: "encryption_version", value: "1.0" },
|
||||
];
|
||||
|
||||
for (const setting of encryptionSettings) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, setting.key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(settings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(settings.key, setting.key));
|
||||
} else {
|
||||
await db.insert(settings).values(setting);
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Encryption settings updated", {
|
||||
operation: "settings_updated",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update settings", error, {
|
||||
operation: "settings_update_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMigration(): Promise<void> {
|
||||
databaseLogger.info("Verifying migration integrity", {
|
||||
operation: "verification_start",
|
||||
});
|
||||
|
||||
try {
|
||||
const status = DatabaseEncryption.getEncryptionStatus();
|
||||
|
||||
if (!status.enabled || !status.configValid) {
|
||||
throw new Error("Encryption system verification failed");
|
||||
}
|
||||
|
||||
const testResult = await this.performTestEncryption();
|
||||
if (!testResult) {
|
||||
throw new Error("Test encryption/decryption failed");
|
||||
}
|
||||
|
||||
databaseLogger.success("Migration verification completed successfully", {
|
||||
operation: "verification_complete",
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Migration verification failed", error, {
|
||||
operation: "verification_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTestEncryption(): Promise<boolean> {
|
||||
try {
|
||||
const { FieldEncryption } = await import("./encryption.js");
|
||||
const testData = `test-data-${Date.now()}`;
|
||||
const testKey = FieldEncryption.getFieldKey(
|
||||
this.config.masterPassword!,
|
||||
"test",
|
||||
);
|
||||
|
||||
const encrypted = FieldEncryption.encryptField(testData, testKey);
|
||||
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
|
||||
|
||||
return decrypted === testData;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkMigrationStatus(): Promise<{
|
||||
isEncryptionEnabled: boolean;
|
||||
migrationCompleted: boolean;
|
||||
migrationRequired: boolean;
|
||||
migrationDate?: string;
|
||||
}> {
|
||||
try {
|
||||
const encryptionEnabled = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "encryption_enabled"));
|
||||
const migrationCompleted = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, "encryption_migration_completed"));
|
||||
|
||||
const isEncryptionEnabled =
|
||||
encryptionEnabled.length > 0 && encryptionEnabled[0].value === "true";
|
||||
const isMigrationCompleted = migrationCompleted.length > 0;
|
||||
|
||||
// Check if migration is actually required by looking for unencrypted sensitive data
|
||||
const migrationRequired = await this.checkIfMigrationRequired();
|
||||
|
||||
return {
|
||||
isEncryptionEnabled,
|
||||
migrationCompleted: isMigrationCompleted,
|
||||
migrationRequired,
|
||||
migrationDate: isMigrationCompleted
|
||||
? migrationCompleted[0].value
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check migration status", error, {
|
||||
operation: "status_check_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkIfMigrationRequired(): Promise<boolean> {
|
||||
try {
|
||||
// Import table schemas
|
||||
const { sshData, sshCredentials } = await import(
|
||||
"../database/db/schema.js"
|
||||
);
|
||||
|
||||
// Check if there's any unencrypted sensitive data in ssh_data
|
||||
const sshDataCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(sshData);
|
||||
if (sshDataCount[0].count > 0) {
|
||||
// Sample a few records to check if they contain unencrypted data
|
||||
const sampleData = await db.select().from(sshData).limit(5);
|
||||
for (const record of sampleData) {
|
||||
if (record.password && !this.looksEncrypted(record.password)) {
|
||||
return true; // Found unencrypted password
|
||||
}
|
||||
if (record.key && !this.looksEncrypted(record.key)) {
|
||||
return true; // Found unencrypted key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's any unencrypted sensitive data in ssh_credentials
|
||||
const credentialsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(sshCredentials);
|
||||
if (credentialsCount[0].count > 0) {
|
||||
const sampleCredentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.limit(5);
|
||||
for (const record of sampleCredentials) {
|
||||
if (record.password && !this.looksEncrypted(record.password)) {
|
||||
return true; // Found unencrypted password
|
||||
}
|
||||
if (record.privateKey && !this.looksEncrypted(record.privateKey)) {
|
||||
return true; // Found unencrypted private key
|
||||
}
|
||||
if (record.keyPassword && !this.looksEncrypted(record.keyPassword)) {
|
||||
return true; // Found unencrypted key password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // No unencrypted sensitive data found
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Failed to check if migration required, assuming required",
|
||||
{
|
||||
operation: "migration_check_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
return true; // If we can't check, assume migration is required for safety
|
||||
}
|
||||
}
|
||||
|
||||
private static looksEncrypted(data: string): boolean {
|
||||
if (!data) return true; // Empty data doesn't need encryption
|
||||
|
||||
try {
|
||||
// Check if it looks like our encrypted format: {"data":"...","iv":"...","tag":"..."}
|
||||
const parsed = JSON.parse(data);
|
||||
return !!(parsed.data && parsed.iv && parsed.tag);
|
||||
} catch {
|
||||
// If it's not JSON, check if it's a reasonable length for encrypted data
|
||||
// Encrypted data is typically much longer than plaintext
|
||||
return data.length > 100 && data.includes("="); // Base64-like characteristics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const config: MigrationConfig = {
|
||||
masterPassword: process.env.DB_ENCRYPTION_KEY,
|
||||
forceEncryption: process.env.FORCE_ENCRYPTION === "true",
|
||||
backupEnabled: process.env.BACKUP_ENABLED !== "false",
|
||||
dryRun: process.env.DRY_RUN === "true",
|
||||
};
|
||||
|
||||
const migration = new EncryptionMigration(config);
|
||||
|
||||
migration
|
||||
.runMigration()
|
||||
.then(() => {
|
||||
console.log("Migration completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { EncryptionMigration };
|
||||
export type { MigrationConfig };
|
||||
@@ -1,341 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { FieldEncryption } from "./encryption.js";
|
||||
import { DatabaseEncryption } from "./database-encryption.js";
|
||||
import { EncryptedDBOperations } from "./encrypted-db-operations.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
class EncryptionTest {
|
||||
private testPassword = "test-master-password-for-validation";
|
||||
|
||||
async runAllTests(): Promise<boolean> {
|
||||
console.log("🔐 Starting Termix Database Encryption Tests...\n");
|
||||
|
||||
const tests = [
|
||||
{
|
||||
name: "Basic Encryption/Decryption",
|
||||
test: () => this.testBasicEncryption(),
|
||||
},
|
||||
{
|
||||
name: "Field Encryption Detection",
|
||||
test: () => this.testFieldDetection(),
|
||||
},
|
||||
{ name: "Key Derivation", test: () => this.testKeyDerivation() },
|
||||
{
|
||||
name: "Database Encryption Context",
|
||||
test: () => this.testDatabaseContext(),
|
||||
},
|
||||
{
|
||||
name: "Record Encryption/Decryption",
|
||||
test: () => this.testRecordOperations(),
|
||||
},
|
||||
{
|
||||
name: "Backward Compatibility",
|
||||
test: () => this.testBackwardCompatibility(),
|
||||
},
|
||||
{ name: "Error Handling", test: () => this.testErrorHandling() },
|
||||
{ name: "Performance Test", test: () => this.testPerformance() },
|
||||
];
|
||||
|
||||
let passedTests = 0;
|
||||
let totalTests = tests.length;
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
console.log(`⏳ Running: ${test.name}...`);
|
||||
await test.test();
|
||||
console.log(`✅ PASSED: ${test.name}\n`);
|
||||
passedTests++;
|
||||
} catch (error) {
|
||||
console.log(`❌ FAILED: ${test.name}`);
|
||||
console.log(
|
||||
` Error: ${error instanceof Error ? error.message : "Unknown error"}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const success = passedTests === totalTests;
|
||||
console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`);
|
||||
|
||||
if (success) {
|
||||
console.log(
|
||||
"🎉 All encryption tests PASSED! System is ready for production.",
|
||||
);
|
||||
} else {
|
||||
console.log("⚠️ Some tests FAILED! Please review the implementation.");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private async testBasicEncryption(): Promise<void> {
|
||||
const testData = "Hello, World! This is sensitive data.";
|
||||
const key = FieldEncryption.getFieldKey(this.testPassword, "test-field");
|
||||
|
||||
const encrypted = FieldEncryption.encryptField(testData, key);
|
||||
const decrypted = FieldEncryption.decryptField(encrypted, key);
|
||||
|
||||
if (decrypted !== testData) {
|
||||
throw new Error(
|
||||
`Decryption mismatch: expected "${testData}", got "${decrypted}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!FieldEncryption.isEncrypted(encrypted)) {
|
||||
throw new Error("Encrypted data not detected as encrypted");
|
||||
}
|
||||
|
||||
if (FieldEncryption.isEncrypted(testData)) {
|
||||
throw new Error("Plain text incorrectly detected as encrypted");
|
||||
}
|
||||
}
|
||||
|
||||
private async testFieldDetection(): Promise<void> {
|
||||
const testCases = [
|
||||
{ table: "users", field: "password_hash", shouldEncrypt: true },
|
||||
{ table: "users", field: "username", shouldEncrypt: false },
|
||||
{ table: "ssh_data", field: "password", shouldEncrypt: true },
|
||||
{ table: "ssh_data", field: "ip", shouldEncrypt: false },
|
||||
{ table: "ssh_credentials", field: "privateKey", shouldEncrypt: true },
|
||||
{ table: "unknown_table", field: "any_field", shouldEncrypt: false },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const result = FieldEncryption.shouldEncryptField(
|
||||
testCase.table,
|
||||
testCase.field,
|
||||
);
|
||||
if (result !== testCase.shouldEncrypt) {
|
||||
throw new Error(
|
||||
`Field detection failed for ${testCase.table}.${testCase.field}: ` +
|
||||
`expected ${testCase.shouldEncrypt}, got ${result}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async testKeyDerivation(): Promise<void> {
|
||||
const password = "test-password";
|
||||
const fieldType1 = "users.password_hash";
|
||||
const fieldType2 = "ssh_data.password";
|
||||
|
||||
const key1a = FieldEncryption.getFieldKey(password, fieldType1);
|
||||
const key1b = FieldEncryption.getFieldKey(password, fieldType1);
|
||||
const key2 = FieldEncryption.getFieldKey(password, fieldType2);
|
||||
|
||||
if (!key1a.equals(key1b)) {
|
||||
throw new Error("Same field type should produce identical keys");
|
||||
}
|
||||
|
||||
if (key1a.equals(key2)) {
|
||||
throw new Error("Different field types should produce different keys");
|
||||
}
|
||||
|
||||
const differentPasswordKey = FieldEncryption.getFieldKey(
|
||||
"different-password",
|
||||
fieldType1,
|
||||
);
|
||||
if (key1a.equals(differentPasswordKey)) {
|
||||
throw new Error("Different passwords should produce different keys");
|
||||
}
|
||||
}
|
||||
|
||||
private async testDatabaseContext(): Promise<void> {
|
||||
DatabaseEncryption.initialize({
|
||||
masterPassword: this.testPassword,
|
||||
encryptionEnabled: true,
|
||||
forceEncryption: false,
|
||||
migrateOnAccess: true,
|
||||
});
|
||||
|
||||
const status = DatabaseEncryption.getEncryptionStatus();
|
||||
if (!status.enabled) {
|
||||
throw new Error("Encryption should be enabled");
|
||||
}
|
||||
|
||||
if (!status.configValid) {
|
||||
throw new Error("Configuration should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
private async testRecordOperations(): Promise<void> {
|
||||
const testRecord = {
|
||||
id: "test-id-123",
|
||||
username: "testuser",
|
||||
password_hash: "sensitive-password-hash",
|
||||
is_admin: false,
|
||||
};
|
||||
|
||||
const encrypted = DatabaseEncryption.encryptRecord("users", testRecord);
|
||||
const decrypted = DatabaseEncryption.decryptRecord("users", encrypted);
|
||||
|
||||
if (decrypted.username !== testRecord.username) {
|
||||
throw new Error("Non-sensitive field should remain unchanged");
|
||||
}
|
||||
|
||||
if (decrypted.password_hash !== testRecord.password_hash) {
|
||||
throw new Error("Sensitive field should be properly decrypted");
|
||||
}
|
||||
|
||||
if (!FieldEncryption.isEncrypted(encrypted.password_hash)) {
|
||||
throw new Error("Sensitive field should be encrypted in stored record");
|
||||
}
|
||||
}
|
||||
|
||||
private async testBackwardCompatibility(): Promise<void> {
|
||||
const plaintextRecord = {
|
||||
id: "legacy-id-456",
|
||||
username: "legacyuser",
|
||||
password_hash: "plain-text-password-hash",
|
||||
is_admin: false,
|
||||
};
|
||||
|
||||
const decrypted = DatabaseEncryption.decryptRecord(
|
||||
"users",
|
||||
plaintextRecord,
|
||||
);
|
||||
|
||||
if (decrypted.password_hash !== plaintextRecord.password_hash) {
|
||||
throw new Error(
|
||||
"Plain text fields should be returned as-is for backward compatibility",
|
||||
);
|
||||
}
|
||||
|
||||
if (decrypted.username !== plaintextRecord.username) {
|
||||
throw new Error("Non-sensitive fields should be unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
private async testErrorHandling(): Promise<void> {
|
||||
const key = FieldEncryption.getFieldKey(this.testPassword, "test");
|
||||
|
||||
try {
|
||||
FieldEncryption.decryptField("invalid-json-data", key);
|
||||
throw new Error("Should have thrown error for invalid JSON");
|
||||
} catch (error) {
|
||||
if (!error || !(error as Error).message.includes("decryption failed")) {
|
||||
throw new Error("Should throw appropriate decryption error");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fakeEncrypted = JSON.stringify({
|
||||
data: "fake",
|
||||
iv: "fake",
|
||||
tag: "fake",
|
||||
});
|
||||
FieldEncryption.decryptField(fakeEncrypted, key);
|
||||
throw new Error("Should have thrown error for invalid encrypted data");
|
||||
} catch (error) {
|
||||
if (!error || !(error as Error).message.includes("Decryption failed")) {
|
||||
throw new Error("Should throw appropriate error for corrupted data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async testPerformance(): Promise<void> {
|
||||
const testData =
|
||||
"Performance test data that is reasonably long to simulate real SSH keys and passwords.";
|
||||
const key = FieldEncryption.getFieldKey(
|
||||
this.testPassword,
|
||||
"performance-test",
|
||||
);
|
||||
|
||||
const iterations = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const encrypted = FieldEncryption.encryptField(testData, key);
|
||||
const decrypted = FieldEncryption.decryptField(encrypted, key);
|
||||
|
||||
if (decrypted !== testData) {
|
||||
throw new Error(`Performance test failed at iteration ${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const totalTime = endTime - startTime;
|
||||
const avgTime = totalTime / iterations;
|
||||
|
||||
console.log(
|
||||
` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`,
|
||||
);
|
||||
|
||||
if (avgTime > 50) {
|
||||
console.log(
|
||||
" ⚠️ Warning: Encryption operations are slower than expected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async validateProduction(): Promise<boolean> {
|
||||
console.log("🔒 Validating production encryption setup...\n");
|
||||
|
||||
try {
|
||||
const encryptionKey = process.env.DB_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
console.log("❌ DB_ENCRYPTION_KEY environment variable not set");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (encryptionKey === "default-key-change-me") {
|
||||
console.log("❌ DB_ENCRYPTION_KEY is using default value (INSECURE)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (encryptionKey.length < 16) {
|
||||
console.log(
|
||||
"❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
DatabaseEncryption.initialize({
|
||||
masterPassword: encryptionKey,
|
||||
encryptionEnabled: true,
|
||||
});
|
||||
|
||||
const status = DatabaseEncryption.getEncryptionStatus();
|
||||
if (!status.configValid) {
|
||||
console.log("❌ Encryption configuration validation failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("✅ Production encryption setup is valid");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`❌ Production validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const testMode = process.argv[2];
|
||||
|
||||
if (testMode === "production") {
|
||||
EncryptionTest.validateProduction()
|
||||
.then((success) => {
|
||||
process.exit(success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Test execution failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
const test = new EncryptionTest();
|
||||
test
|
||||
.runAllTests()
|
||||
.then((success) => {
|
||||
process.exit(success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Test execution failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { EncryptionTest };
|
||||
@@ -1,172 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
interface EncryptedData {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
interface EncryptionConfig {
|
||||
algorithm: string;
|
||||
keyLength: number;
|
||||
ivLength: number;
|
||||
saltLength: number;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
class FieldEncryption {
|
||||
private static readonly CONFIG: EncryptionConfig = {
|
||||
algorithm: "aes-256-gcm",
|
||||
keyLength: 32,
|
||||
ivLength: 16,
|
||||
saltLength: 32,
|
||||
iterations: 100000,
|
||||
};
|
||||
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: [
|
||||
"password_hash",
|
||||
"client_secret",
|
||||
"totp_secret",
|
||||
"totp_backup_codes",
|
||||
"oidc_identifier",
|
||||
],
|
||||
ssh_data: ["password", "key", "keyPassword"],
|
||||
ssh_credentials: [
|
||||
"password",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"key",
|
||||
"publicKey",
|
||||
],
|
||||
};
|
||||
|
||||
static isEncrypted(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!(parsed.data && parsed.iv && parsed.tag);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static deriveKey(password: string, salt: Buffer, keyType: string): Buffer {
|
||||
const masterKey = crypto.pbkdf2Sync(
|
||||
password,
|
||||
salt,
|
||||
this.CONFIG.iterations,
|
||||
this.CONFIG.keyLength,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
return Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
"sha256",
|
||||
masterKey,
|
||||
salt,
|
||||
keyType,
|
||||
this.CONFIG.keyLength,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static encrypt(plaintext: string, key: Buffer): EncryptedData {
|
||||
if (!plaintext) return { data: "", iv: "", tag: "" };
|
||||
|
||||
const iv = crypto.randomBytes(this.CONFIG.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any;
|
||||
cipher.setAAD(Buffer.from("termix-field-encryption"));
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
data: encrypted,
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
static decrypt(encryptedData: EncryptedData, key: Buffer): string {
|
||||
if (!encryptedData.data) return "";
|
||||
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
this.CONFIG.algorithm,
|
||||
key,
|
||||
Buffer.from(encryptedData.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAAD(Buffer.from("termix-field-encryption"));
|
||||
decipher.setAuthTag(Buffer.from(encryptedData.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encryptedData.data, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static encryptField(value: string, fieldKey: Buffer): string {
|
||||
if (!value) return "";
|
||||
if (this.isEncrypted(value)) return value;
|
||||
|
||||
const encrypted = this.encrypt(value, fieldKey);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
static decryptField(value: string, fieldKey: Buffer): string {
|
||||
if (!value) return "";
|
||||
if (!this.isEncrypted(value)) return value;
|
||||
|
||||
try {
|
||||
const encrypted: EncryptedData = JSON.parse(value);
|
||||
return this.decrypt(encrypted, fieldKey);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Field decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static getFieldKey(masterPassword: string, fieldType: string): Buffer {
|
||||
const salt = crypto
|
||||
.createHash("sha256")
|
||||
.update(`termix-${fieldType}`)
|
||||
.digest();
|
||||
return this.deriveKey(masterPassword, salt, fieldType);
|
||||
}
|
||||
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const tableFields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return tableFields ? tableFields.includes(fieldName) : false;
|
||||
}
|
||||
|
||||
static generateSalt(): string {
|
||||
return crypto.randomBytes(this.CONFIG.saltLength).toString("hex");
|
||||
}
|
||||
|
||||
static validateEncryptionHealth(
|
||||
encryptedValue: string,
|
||||
key: Buffer,
|
||||
): boolean {
|
||||
try {
|
||||
if (!this.isEncrypted(encryptedValue)) return false;
|
||||
const decrypted = this.decryptField(encryptedValue, key);
|
||||
return decrypted !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldEncryption };
|
||||
export type { EncryptedData, EncryptionConfig };
|
||||
95
src/backend/utils/field-crypto.ts
Normal file
95
src/backend/utils/field-crypto.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
interface EncryptedData {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
salt: string;
|
||||
recordId: string; // Store the recordId used for encryption context
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldCrypto - Simple direct field encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - No special cases
|
||||
* - No compatibility checks
|
||||
* - Data is either encrypted or fails
|
||||
* - No "legacy data" concept
|
||||
*/
|
||||
class FieldCrypto {
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
private static readonly KEY_LENGTH = 32;
|
||||
private static readonly IV_LENGTH = 16;
|
||||
private static readonly SALT_LENGTH = 32;
|
||||
|
||||
// Fields requiring encryption - simple mapping, no complex logic
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set(["password_hash", "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier"]),
|
||||
ssh_data: new Set(["password", "key", "keyPassword"]),
|
||||
ssh_credentials: new Set(["password", "privateKey", "keyPassword", "key", "publicKey"]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypt field - no special cases
|
||||
*/
|
||||
static encryptField(plaintext: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
||||
if (!plaintext) return "";
|
||||
|
||||
const salt = crypto.randomBytes(this.SALT_LENGTH);
|
||||
const context = `${recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
||||
|
||||
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const encryptedData: EncryptedData = {
|
||||
data: encrypted,
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
salt: salt.toString("hex"),
|
||||
recordId: recordId, // Store recordId for consistent decryption context
|
||||
};
|
||||
|
||||
return JSON.stringify(encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt field - either succeeds or fails, no third option
|
||||
*/
|
||||
static decryptField(encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
||||
if (!encryptedValue) return "";
|
||||
|
||||
const encrypted: EncryptedData = JSON.parse(encryptedValue);
|
||||
const salt = Buffer.from(encrypted.salt, "hex");
|
||||
|
||||
// Use ONLY the recordId that was stored during encryption
|
||||
if (!encrypted.recordId) {
|
||||
throw new Error(`Encrypted field missing recordId context - data corruption or legacy format not supported`);
|
||||
}
|
||||
const context = `${encrypted.recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ALGORITHM, fieldKey, Buffer.from(encrypted.iv, "hex")) as any;
|
||||
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field needs encryption - simple table lookup, no complex logic
|
||||
*/
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return fields ? fields.has(fieldName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldCrypto, type EncryptedData };
|
||||
@@ -1,436 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import os from "os";
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface HardwareInfo {
|
||||
cpuId?: string;
|
||||
motherboardUuid?: string;
|
||||
diskSerial?: string;
|
||||
biosSerial?: string;
|
||||
tpmInfo?: string;
|
||||
macAddresses?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬件指纹生成器 - 使用真实硬件特征生成稳定的设备指纹
|
||||
* 相比软件环境指纹,硬件指纹在虚拟化和容器环境中更加稳定
|
||||
*/
|
||||
class HardwareFingerprint {
|
||||
private static readonly CACHE_KEY = "cached_hardware_fingerprint";
|
||||
private static cachedFingerprint: string | null = null;
|
||||
|
||||
/**
|
||||
* 生成硬件指纹
|
||||
* 优先级:缓存 > 环境变量 > 硬件检测
|
||||
*/
|
||||
static generate(): string {
|
||||
try {
|
||||
if (this.cachedFingerprint) {
|
||||
return this.cachedFingerprint;
|
||||
}
|
||||
|
||||
const envFingerprint = process.env.TERMIX_HARDWARE_SEED;
|
||||
if (envFingerprint && envFingerprint.length >= 32) {
|
||||
databaseLogger.info("Using hardware seed from environment variable", {
|
||||
operation: "hardware_fingerprint_env",
|
||||
});
|
||||
this.cachedFingerprint = this.hashFingerprint(envFingerprint);
|
||||
return this.cachedFingerprint;
|
||||
}
|
||||
|
||||
const hwInfo = this.detectHardwareInfo();
|
||||
const fingerprint = this.generateFromHardware(hwInfo);
|
||||
|
||||
this.cachedFingerprint = fingerprint;
|
||||
|
||||
return fingerprint;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Hardware fingerprint generation failed", error, {
|
||||
operation: "hardware_fingerprint_failed",
|
||||
});
|
||||
|
||||
return this.generateFallbackFingerprint();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测硬件信息
|
||||
*/
|
||||
private static detectHardwareInfo(): HardwareInfo {
|
||||
const platform = os.platform();
|
||||
const hwInfo: HardwareInfo = {};
|
||||
|
||||
try {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
hwInfo.cpuId = this.getLinuxCpuId();
|
||||
hwInfo.motherboardUuid = this.getLinuxMotherboardUuid();
|
||||
hwInfo.diskSerial = this.getLinuxDiskSerial();
|
||||
hwInfo.biosSerial = this.getLinuxBiosSerial();
|
||||
break;
|
||||
|
||||
case "win32":
|
||||
hwInfo.cpuId = this.getWindowsCpuId();
|
||||
hwInfo.motherboardUuid = this.getWindowsMotherboardUuid();
|
||||
hwInfo.diskSerial = this.getWindowsDiskSerial();
|
||||
hwInfo.biosSerial = this.getWindowsBiosSerial();
|
||||
break;
|
||||
|
||||
case "darwin":
|
||||
hwInfo.cpuId = this.getMacOSCpuId();
|
||||
hwInfo.motherboardUuid = this.getMacOSMotherboardUuid();
|
||||
hwInfo.diskSerial = this.getMacOSDiskSerial();
|
||||
hwInfo.biosSerial = this.getMacOSBiosSerial();
|
||||
break;
|
||||
}
|
||||
|
||||
// 所有平台都尝试获取MAC地址
|
||||
hwInfo.macAddresses = this.getStableMacAddresses();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Some hardware detection failed", error, {
|
||||
operation: "hardware_detection_partial_failure",
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
return hwInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux平台硬件信息获取
|
||||
*/
|
||||
private static getLinuxCpuId(): string | undefined {
|
||||
try {
|
||||
// 尝试多种方法获取CPU信息
|
||||
const methods = [
|
||||
() =>
|
||||
fs
|
||||
.readFileSync("/proc/cpuinfo", "utf8")
|
||||
.match(/processor\s*:\s*(\d+)/)?.[1],
|
||||
() =>
|
||||
execSync('dmidecode -t processor | grep "ID:" | head -1', {
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
() =>
|
||||
execSync(
|
||||
'cat /proc/cpuinfo | grep "cpu family\\|model\\|stepping" | md5sum',
|
||||
{ encoding: "utf8" },
|
||||
).split(" ")[0],
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
try {
|
||||
const result = method();
|
||||
if (result && result.length > 0) return result;
|
||||
} catch {
|
||||
/* 继续尝试下一种方法 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getLinuxMotherboardUuid(): string | undefined {
|
||||
try {
|
||||
// 尝试多种方法获取主板UUID
|
||||
const methods = [
|
||||
() => fs.readFileSync("/sys/class/dmi/id/product_uuid", "utf8").trim(),
|
||||
() => fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
|
||||
() => execSync("dmidecode -s system-uuid", { encoding: "utf8" }).trim(),
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
try {
|
||||
const result = method();
|
||||
if (result && result.length > 0 && result !== "Not Settable")
|
||||
return result;
|
||||
} catch {
|
||||
/* 继续尝试下一种方法 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getLinuxDiskSerial(): string | undefined {
|
||||
try {
|
||||
// 获取根分区所在磁盘的序列号
|
||||
const rootDisk = execSync(
|
||||
"df / | tail -1 | awk '{print $1}' | sed 's/[0-9]*$//'",
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
if (rootDisk) {
|
||||
const serial = execSync(
|
||||
`udevadm info --name=${rootDisk} | grep ID_SERIAL= | cut -d= -f2`,
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
if (serial && serial.length > 0) return serial;
|
||||
}
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getLinuxBiosSerial(): string | undefined {
|
||||
try {
|
||||
const methods = [
|
||||
() => fs.readFileSync("/sys/class/dmi/id/board_serial", "utf8").trim(),
|
||||
() =>
|
||||
execSync("dmidecode -s baseboard-serial-number", {
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
try {
|
||||
const result = method();
|
||||
if (result && result.length > 0 && result !== "Not Specified")
|
||||
return result;
|
||||
} catch {
|
||||
/* 继续尝试下一种方法 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows平台硬件信息获取
|
||||
*/
|
||||
private static getWindowsCpuId(): string | undefined {
|
||||
try {
|
||||
const result = execSync("wmic cpu get ProcessorId /value", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const match = result.match(/ProcessorId=(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getWindowsMotherboardUuid(): string | undefined {
|
||||
try {
|
||||
const result = execSync("wmic csproduct get UUID /value", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const match = result.match(/UUID=(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getWindowsDiskSerial(): string | undefined {
|
||||
try {
|
||||
const result = execSync("wmic diskdrive get SerialNumber /value", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const match = result.match(/SerialNumber=(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getWindowsBiosSerial(): string | undefined {
|
||||
try {
|
||||
const result = execSync("wmic baseboard get SerialNumber /value", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const match = result.match(/SerialNumber=(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS平台硬件信息获取
|
||||
*/
|
||||
private static getMacOSCpuId(): string | undefined {
|
||||
try {
|
||||
const result = execSync("sysctl -n machdep.cpu.brand_string", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return result.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getMacOSMotherboardUuid(): string | undefined {
|
||||
try {
|
||||
const result = execSync(
|
||||
'system_profiler SPHardwareDataType | grep "Hardware UUID"',
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const match = result.match(/Hardware UUID:\s*(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getMacOSDiskSerial(): string | undefined {
|
||||
try {
|
||||
const result = execSync(
|
||||
'system_profiler SPStorageDataType | grep "Serial Number"',
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const match = result.match(/Serial Number:\s*(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getMacOSBiosSerial(): string | undefined {
|
||||
try {
|
||||
const result = execSync(
|
||||
'system_profiler SPHardwareDataType | grep "Serial Number"',
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const match = result.match(/Serial Number \(system\):\s*(.+)/);
|
||||
return match?.[1]?.trim();
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取稳定的MAC地址
|
||||
* 排除虚拟接口和临时接口
|
||||
*/
|
||||
private static getStableMacAddresses(): string[] {
|
||||
try {
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
const macAddresses: string[] = [];
|
||||
|
||||
for (const [interfaceName, interfaces] of Object.entries(
|
||||
networkInterfaces,
|
||||
)) {
|
||||
if (!interfaces) continue;
|
||||
|
||||
// 排除虚拟接口和Docker接口
|
||||
if (interfaceName.match(/^(lo|docker|veth|br-|virbr)/)) continue;
|
||||
|
||||
for (const iface of interfaces) {
|
||||
if (
|
||||
!iface.internal &&
|
||||
iface.mac &&
|
||||
iface.mac !== "00:00:00:00:00:00" &&
|
||||
!iface.mac.startsWith("02:42:")
|
||||
) {
|
||||
// Docker接口特征
|
||||
macAddresses.push(iface.mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return macAddresses.sort(); // 排序确保一致性
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从硬件信息生成指纹
|
||||
*/
|
||||
private static generateFromHardware(hwInfo: HardwareInfo): string {
|
||||
const components = [
|
||||
hwInfo.motherboardUuid, // 最稳定的标识符
|
||||
hwInfo.cpuId,
|
||||
hwInfo.biosSerial,
|
||||
hwInfo.diskSerial,
|
||||
hwInfo.macAddresses?.join(","),
|
||||
os.platform(), // 操作系统平台
|
||||
os.arch(), // CPU架构
|
||||
].filter(Boolean); // 过滤空值
|
||||
|
||||
if (components.length === 0) {
|
||||
throw new Error("No hardware identifiers found");
|
||||
}
|
||||
|
||||
return this.hashFingerprint(components.join("|"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成回退指纹(当硬件检测失败时)
|
||||
*/
|
||||
private static generateFallbackFingerprint(): string {
|
||||
const fallbackComponents = [
|
||||
os.hostname(),
|
||||
os.platform(),
|
||||
os.arch(),
|
||||
process.cwd(),
|
||||
"fallback-mode",
|
||||
];
|
||||
|
||||
databaseLogger.warn(
|
||||
"Using fallback fingerprint due to hardware detection failure",
|
||||
{
|
||||
operation: "hardware_fingerprint_fallback",
|
||||
},
|
||||
);
|
||||
|
||||
return this.hashFingerprint(fallbackComponents.join("|"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化指纹哈希
|
||||
*/
|
||||
private static hashFingerprint(data: string): string {
|
||||
return crypto.createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取硬件指纹信息(用于调试和显示)
|
||||
*/
|
||||
static getHardwareInfo(): HardwareInfo & { fingerprint: string } {
|
||||
const hwInfo = this.detectHardwareInfo();
|
||||
return {
|
||||
...hwInfo,
|
||||
fingerprint: this.generate().substring(0, 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前硬件指纹
|
||||
*/
|
||||
static validateFingerprint(expectedFingerprint: string): boolean {
|
||||
try {
|
||||
const currentFingerprint = this.generate();
|
||||
return currentFingerprint === expectedFingerprint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试)
|
||||
*/
|
||||
static clearCache(): void {
|
||||
this.cachedFingerprint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { HardwareFingerprint };
|
||||
export type { HardwareInfo };
|
||||
295
src/backend/utils/lazy-field-encryption.ts
Normal file
295
src/backend/utils/lazy-field-encryption.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* 延迟字段加密 - 处理从明文到加密的平滑迁移
|
||||
* 用于在用户登录时将明文敏感数据逐步加密
|
||||
*/
|
||||
export class LazyFieldEncryption {
|
||||
/**
|
||||
* 检测字段是否为明文(未加密)
|
||||
*/
|
||||
static isPlaintextField(value: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
// 如果能解析为JSON且包含加密数据结构,则认为已加密
|
||||
if (parsed && typeof parsed === 'object' &&
|
||||
parsed.data && parsed.iv && parsed.tag && parsed.salt && parsed.recordId) {
|
||||
return false; // 已加密
|
||||
}
|
||||
// JSON格式但不是加密结构,视为明文
|
||||
return true;
|
||||
} catch (jsonError) {
|
||||
// 无法解析为JSON,视为明文
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取字段值 - 自动处理明文和加密数据
|
||||
* 如果是明文,直接返回;如果已加密,则解密
|
||||
*/
|
||||
static safeGetFieldValue(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string
|
||||
): string {
|
||||
if (!fieldValue) return "";
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
// 明文数据,直接返回
|
||||
databaseLogger.debug("Field detected as plaintext, returning as-is", {
|
||||
operation: "lazy_encryption_plaintext_detected",
|
||||
recordId,
|
||||
fieldName,
|
||||
valuePreview: fieldValue.substring(0, 10) + "...",
|
||||
});
|
||||
return fieldValue;
|
||||
} else {
|
||||
// 加密数据,需要解密
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
databaseLogger.debug("Field decrypted successfully", {
|
||||
operation: "lazy_encryption_decrypt_success",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt field", error, {
|
||||
operation: "lazy_encryption_decrypt_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移明文字段到加密状态
|
||||
* 返回加密后的值,如果已经加密则返回原值
|
||||
*/
|
||||
static migrateFieldToEncrypted(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string
|
||||
): { encrypted: string; wasPlaintext: boolean } {
|
||||
if (!fieldValue) {
|
||||
return { encrypted: "", wasPlaintext: false };
|
||||
}
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
// 明文数据,需要加密
|
||||
try {
|
||||
const encrypted = FieldCrypto.encryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
|
||||
databaseLogger.info("Field migrated from plaintext to encrypted", {
|
||||
operation: "lazy_encryption_migrate_success",
|
||||
recordId,
|
||||
fieldName,
|
||||
plaintextLength: fieldValue.length,
|
||||
});
|
||||
|
||||
return { encrypted, wasPlaintext: true };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt plaintext field", error, {
|
||||
operation: "lazy_encryption_migrate_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 已经加密,无需处理
|
||||
databaseLogger.debug("Field already encrypted, no migration needed", {
|
||||
operation: "lazy_encryption_already_encrypted",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
return { encrypted: fieldValue, wasPlaintext: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量迁移记录中的敏感字段
|
||||
*/
|
||||
static migrateRecordSensitiveFields(
|
||||
record: any,
|
||||
sensitiveFields: string[],
|
||||
userKEK: Buffer,
|
||||
recordId: string
|
||||
): {
|
||||
updatedRecord: any;
|
||||
migratedFields: string[];
|
||||
needsUpdate: boolean
|
||||
} {
|
||||
const updatedRecord = { ...record };
|
||||
const migratedFields: string[] = [];
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const fieldName of sensitiveFields) {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (fieldValue && this.isPlaintextField(fieldValue)) {
|
||||
try {
|
||||
const { encrypted } = this.migrateFieldToEncrypted(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName
|
||||
);
|
||||
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
migratedFields.push(fieldName);
|
||||
needsUpdate = true;
|
||||
|
||||
databaseLogger.debug("Record field migrated to encrypted", {
|
||||
operation: "lazy_encryption_record_field_migrated",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate record field", error, {
|
||||
operation: "lazy_encryption_record_field_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
// 不抛出错误,继续处理其他字段
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
databaseLogger.info("Record requires sensitive field migration", {
|
||||
operation: "lazy_encryption_record_migration_needed",
|
||||
recordId,
|
||||
migratedFields,
|
||||
totalMigratedFields: migratedFields.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedRecord, migratedFields, needsUpdate };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取敏感字段列表 - 定义哪些字段需要延迟加密
|
||||
*/
|
||||
static getSensitiveFieldsForTable(tableName: string): string[] {
|
||||
const sensitiveFieldsMap: Record<string, string[]> = {
|
||||
'ssh_data': ['password', 'key', 'key_password'],
|
||||
'ssh_credentials': ['password', 'key', 'key_password', 'private_key'],
|
||||
'users': ['totp_secret', 'totp_backup_codes'],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有需要迁移的明文数据
|
||||
*/
|
||||
static async checkUserNeedsMigration(
|
||||
userId: string,
|
||||
userKEK: Buffer,
|
||||
db: any
|
||||
): Promise<{
|
||||
needsMigration: boolean;
|
||||
plaintextFields: Array<{ table: string; recordId: string; fields: string[] }>;
|
||||
}> {
|
||||
const plaintextFields: Array<{ table: string; recordId: string; fields: string[] }> = [];
|
||||
let needsMigration = false;
|
||||
|
||||
try {
|
||||
// 检查 ssh_data 表
|
||||
const sshHosts = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
|
||||
for (const host of sshHosts) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_data');
|
||||
const hostPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (host[field] && this.isPlaintextField(host[field])) {
|
||||
hostPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'ssh_data',
|
||||
recordId: host.id.toString(),
|
||||
fields: hostPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 ssh_credentials 表
|
||||
const sshCredentials = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
||||
for (const credential of sshCredentials) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_credentials');
|
||||
const credentialPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (credential[field] && this.isPlaintextField(credential[field])) {
|
||||
credentialPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'ssh_credentials',
|
||||
recordId: credential.id.toString(),
|
||||
fields: credentialPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 users 表中的敏感字段
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
if (user) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('users');
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (user[field] && this.isPlaintextField(user[field])) {
|
||||
userPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (userPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'users',
|
||||
recordId: userId,
|
||||
fields: userPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("User migration check completed", {
|
||||
operation: "lazy_encryption_user_check",
|
||||
userId,
|
||||
needsMigration,
|
||||
plaintextFieldsCount: plaintextFields.length,
|
||||
totalPlaintextFields: plaintextFields.reduce((sum, item) => sum + item.fields.length, 0),
|
||||
});
|
||||
|
||||
return { needsMigration, plaintextFields };
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check user migration needs", error, {
|
||||
operation: "lazy_encryption_user_check_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return { needsMigration: false, plaintextFields: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { HardwareFingerprint } from "./hardware-fingerprint.js";
|
||||
|
||||
interface ProtectedKeyData {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
class MasterKeyProtection {
|
||||
private static readonly VERSION = "v1";
|
||||
private static readonly KEK_SALT = "termix-kek-salt-v1";
|
||||
private static readonly KEK_ITERATIONS = 50000;
|
||||
|
||||
private static generateDeviceFingerprint(): string {
|
||||
try {
|
||||
const fingerprint = HardwareFingerprint.generate();
|
||||
|
||||
return fingerprint;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to generate hardware fingerprint", error, {
|
||||
operation: "hardware_fingerprint_generation_failed",
|
||||
});
|
||||
throw new Error("Hardware fingerprint generation failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static deriveKEK(): Buffer {
|
||||
const fingerprint = this.generateDeviceFingerprint();
|
||||
const salt = Buffer.from(this.KEK_SALT);
|
||||
|
||||
const kek = crypto.pbkdf2Sync(
|
||||
fingerprint,
|
||||
salt,
|
||||
this.KEK_ITERATIONS,
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
return kek;
|
||||
}
|
||||
|
||||
static encryptMasterKey(masterKey: string): string {
|
||||
if (!masterKey) {
|
||||
throw new Error("Master key cannot be empty");
|
||||
}
|
||||
|
||||
try {
|
||||
const kek = this.deriveKEK();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv) as any;
|
||||
|
||||
let encrypted = cipher.update(masterKey, "hex", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const protectedData: ProtectedKeyData = {
|
||||
data: encrypted,
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: this.generateDeviceFingerprint().substring(0, 16),
|
||||
};
|
||||
|
||||
const result = JSON.stringify(protectedData);
|
||||
|
||||
databaseLogger.info("Master key encrypted with hardware KEK", {
|
||||
operation: "master_key_encryption",
|
||||
version: this.VERSION,
|
||||
fingerprintPrefix: protectedData.fingerprint,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt master key", error, {
|
||||
operation: "master_key_encryption_failed",
|
||||
});
|
||||
throw new Error("Master key encryption failed");
|
||||
}
|
||||
}
|
||||
|
||||
static decryptMasterKey(encryptedKey: string): string {
|
||||
if (!encryptedKey) {
|
||||
throw new Error("Encrypted key cannot be empty");
|
||||
}
|
||||
|
||||
try {
|
||||
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
|
||||
|
||||
if (protectedData.version !== this.VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported protection version: ${protectedData.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const currentFingerprint = this.generateDeviceFingerprint().substring(
|
||||
0,
|
||||
16,
|
||||
);
|
||||
if (protectedData.fingerprint !== currentFingerprint) {
|
||||
databaseLogger.warn("Hardware fingerprint mismatch detected", {
|
||||
operation: "master_key_decryption",
|
||||
expected: protectedData.fingerprint,
|
||||
current: currentFingerprint,
|
||||
});
|
||||
throw new Error(
|
||||
"Hardware fingerprint mismatch - key was encrypted on different hardware",
|
||||
);
|
||||
}
|
||||
|
||||
const kek = this.deriveKEK();
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
kek,
|
||||
Buffer.from(protectedData.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAuthTag(Buffer.from(protectedData.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(protectedData.data, "hex", "hex");
|
||||
decrypted += decipher.final("hex");
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt master key", error, {
|
||||
operation: "master_key_decryption_failed",
|
||||
});
|
||||
throw new Error(
|
||||
`Master key decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static isProtectedKey(data: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return !!(
|
||||
parsed.data &&
|
||||
parsed.iv &&
|
||||
parsed.tag &&
|
||||
parsed.version &&
|
||||
parsed.fingerprint
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static validateProtection(): boolean {
|
||||
try {
|
||||
const testKey = crypto.randomBytes(32).toString("hex");
|
||||
const encrypted = this.encryptMasterKey(testKey);
|
||||
const decrypted = this.decryptMasterKey(encrypted);
|
||||
|
||||
const isValid = decrypted === testKey;
|
||||
|
||||
databaseLogger.info("Master key protection validation completed", {
|
||||
operation: "protection_validation",
|
||||
result: isValid ? "passed" : "failed",
|
||||
});
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Master key protection validation failed", error, {
|
||||
operation: "protection_validation_failed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getProtectionInfo(encryptedKey: string): {
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
isCurrentDevice: boolean;
|
||||
} | null {
|
||||
try {
|
||||
if (!this.isProtectedKey(encryptedKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
|
||||
const currentFingerprint = this.generateDeviceFingerprint().substring(
|
||||
0,
|
||||
16,
|
||||
);
|
||||
|
||||
return {
|
||||
version: protectedData.version,
|
||||
fingerprint: protectedData.fingerprint,
|
||||
isCurrentDevice: protectedData.fingerprint === currentFingerprint,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { MasterKeyProtection };
|
||||
export type { ProtectedKeyData };
|
||||
204
src/backend/utils/simple-db-ops.ts
Normal file
204
src/backend/utils/simple-db-ops.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
|
||||
/**
|
||||
* SimpleDBOps - Simplified encrypted database operations
|
||||
*
|
||||
* Linus-style simplification:
|
||||
* - Remove all complex abstraction layers
|
||||
* - Direct CRUD operations
|
||||
* - Automatic encryption/decryption
|
||||
* - No special case handling
|
||||
*/
|
||||
class SimpleDBOps {
|
||||
/**
|
||||
* Insert encrypted record
|
||||
*/
|
||||
static async insert<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
userId: string,
|
||||
): Promise<T> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Generate consistent temporary ID for encryption context if record has no ID
|
||||
const tempId = data.id || `temp-${userId}-${Date.now()}`;
|
||||
const dataWithTempId = { ...data, id: tempId };
|
||||
|
||||
// Encrypt data using the locked key - recordId will be stored in encrypted fields
|
||||
const encryptedData = DataCrypto.encryptRecord(tableName, dataWithTempId, userId, userDataKey);
|
||||
|
||||
// Remove temp ID if it was generated, let database assign real ID
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
const result = await getDb().insert(table).values(encryptedData).returning();
|
||||
|
||||
// Trigger database save after insert
|
||||
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
|
||||
|
||||
// Decrypt return result using the same key - FieldCrypto will use stored recordId
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result[0],
|
||||
userId,
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
|
||||
operation: "simple_insert",
|
||||
table: tableName,
|
||||
userId,
|
||||
recordId: result[0].id,
|
||||
});
|
||||
|
||||
return decryptedResult as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query multiple records
|
||||
*/
|
||||
static async select<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Execute query
|
||||
const results = await query;
|
||||
|
||||
// Decrypt results using locked key
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
results,
|
||||
userId,
|
||||
userDataKey
|
||||
);
|
||||
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query single record
|
||||
*/
|
||||
static async selectOne<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T | undefined> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Execute query
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
// Decrypt results using locked key
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Selected single record from ${tableName}`, {
|
||||
operation: "simple_select_one",
|
||||
table: tableName,
|
||||
userId,
|
||||
recordId: result.id,
|
||||
});
|
||||
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update record
|
||||
*/
|
||||
static async update<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
data: Partial<T>,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Encrypt update data using the locked key
|
||||
const encryptedData = DataCrypto.encryptRecord(tableName, data, userId, userDataKey);
|
||||
|
||||
// Execute update
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
.where(where)
|
||||
.returning();
|
||||
|
||||
// Trigger database save after update
|
||||
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
||||
|
||||
// Decrypt return data using the same key
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Updated records in ${tableName}`, {
|
||||
operation: "simple_update",
|
||||
table: tableName,
|
||||
userId,
|
||||
updatedCount: result.length,
|
||||
});
|
||||
|
||||
return decryptedResults as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete record
|
||||
*/
|
||||
static async delete(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
userId: string,
|
||||
): Promise<any[]> {
|
||||
const result = await getDb().delete(table).where(where).returning();
|
||||
|
||||
// Trigger database save after delete
|
||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
static async healthCheck(userId: string): Promise<boolean> {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Special method: return encrypted data (for auto-start scenarios)
|
||||
* No decryption, return data in encrypted state directly
|
||||
*/
|
||||
static async selectEncrypted(query: any, tableName: TableName): Promise<any[]> {
|
||||
// Execute query directly, no decryption
|
||||
const results = await query;
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export { SimpleDBOps, type TableName };
|
||||
329
src/backend/utils/system-crypto.ts
Normal file
329
src/backend/utils/system-crypto.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* SystemCrypto - Open source friendly system key management
|
||||
*
|
||||
* Linus principles:
|
||||
* - Remove complex "system master key" layer - doesn't solve real threats
|
||||
* - Remove hardcoded default keys - security disaster for open source software
|
||||
* - Auto-generate on first startup - each instance independently secure
|
||||
* - Simple and direct, focus on real security boundaries
|
||||
*/
|
||||
class SystemCrypto {
|
||||
private static instance: SystemCrypto;
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SystemCrypto {
|
||||
if (!this.instance) {
|
||||
this.instance = new SystemCrypto();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize JWT secret - environment variable only
|
||||
*/
|
||||
async initializeJWTSecret(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing JWT secret", {
|
||||
operation: "jwt_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
this.jwtSecret = envSecret;
|
||||
databaseLogger.info("✅ Using JWT secret from environment variable", {
|
||||
operation: "jwt_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideUser();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
operation: "jwt_init_failed",
|
||||
});
|
||||
throw new Error("JWT secret initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT secret
|
||||
*/
|
||||
async getJWTSecret(): Promise<string> {
|
||||
if (!this.jwtSecret) {
|
||||
await this.initializeJWTSecret();
|
||||
}
|
||||
return this.jwtSecret!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database encryption key - environment variable only
|
||||
*/
|
||||
async initializeDatabaseKey(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing database encryption key", {
|
||||
operation: "db_key_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||
databaseLogger.info("✅ Using database key from environment variable", {
|
||||
operation: "db_key_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
operation: "db_key_init_failed",
|
||||
});
|
||||
throw new Error("Database key initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database encryption key
|
||||
*/
|
||||
async getDatabaseKey(): Promise<Buffer> {
|
||||
if (!this.databaseKey) {
|
||||
await this.initializeDatabaseKey();
|
||||
}
|
||||
return this.databaseKey!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize internal auth token - environment variable only
|
||||
*/
|
||||
async initializeInternalAuthToken(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing internal auth token", {
|
||||
operation: "internal_auth_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
if (envToken && envToken.length >= 32) {
|
||||
this.internalAuthToken = envToken;
|
||||
databaseLogger.info("✅ Using internal auth token from environment variable", {
|
||||
operation: "internal_auth_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
operation: "internal_auth_init_failed",
|
||||
});
|
||||
throw new Error("Internal auth token initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get internal auth token
|
||||
*/
|
||||
async getInternalAuthToken(): Promise<string> {
|
||||
if (!this.internalAuthToken) {
|
||||
await this.initializeInternalAuthToken();
|
||||
}
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and auto-save to .env file
|
||||
*/
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
// Set in memory for current session
|
||||
this.jwtSecret = newSecret;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||
|
||||
databaseLogger.success("🔐 JWT secret auto-generated and saved to .env", {
|
||||
operation: "jwt_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "JWT_SECRET",
|
||||
note: "Ready for use - no restart required"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== Database key generation and storage methods =====
|
||||
|
||||
/**
|
||||
* Generate and auto-save database key to .env file
|
||||
*/
|
||||
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
||||
const newKeyHex = newKey.toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
// Set in memory for current session
|
||||
this.databaseKey = newKey;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success("🔒 Database key auto-generated and saved to .env", {
|
||||
operation: "db_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "DATABASE_KEY",
|
||||
note: "Ready for use - no restart required"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and auto-save internal auth token to .env file
|
||||
*/
|
||||
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
||||
const newToken = crypto.randomBytes(32).toString('hex'); // 256-bit token for security
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
// Set in memory for current session
|
||||
this.internalAuthToken = newToken;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||
|
||||
databaseLogger.success("🔑 Internal auth token auto-generated and saved to .env", {
|
||||
operation: "internal_auth_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||
note: "Ready for use - no restart required"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Validate JWT secret system
|
||||
*/
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
if (!secret || secret.length < 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test JWT operations
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const testPayload = { test: true, timestamp: Date.now() };
|
||||
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
|
||||
const decoded = jwt.default.verify(token, secret);
|
||||
|
||||
return !!decoded;
|
||||
} catch (error) {
|
||||
databaseLogger.error("JWT secret validation failed", error, {
|
||||
operation: "jwt_validation_failed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT key status (simplified version)
|
||||
*/
|
||||
async getSystemKeyStatus() {
|
||||
const isValid = await this.validateJWTSecret();
|
||||
const hasSecret = this.jwtSecret !== null;
|
||||
|
||||
|
||||
// Check environment variable
|
||||
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
||||
|
||||
return {
|
||||
hasSecret,
|
||||
isValid,
|
||||
storage: {
|
||||
environment: hasEnvVar
|
||||
},
|
||||
algorithm: "HS256",
|
||||
note: "Using simplified key management without encryption layers"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update .env file with new environment variable
|
||||
*/
|
||||
private async updateEnvFile(key: string, value: string): Promise<void> {
|
||||
// Use persistent config directory if available (Docker), otherwise use current directory
|
||||
const configDir = process.env.NODE_ENV === 'production' &&
|
||||
await fs.access('/app/config').then(() => true).catch(() => false)
|
||||
? '/app/config'
|
||||
: process.cwd();
|
||||
const envPath = path.join(configDir, ".env");
|
||||
|
||||
try {
|
||||
let envContent = "";
|
||||
|
||||
// Read existing .env file if it exists
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, "utf8");
|
||||
} catch {
|
||||
// File doesn't exist, will create new one
|
||||
envContent = "# Termix Auto-generated Configuration\n\n";
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
const keyRegex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (keyRegex.test(envContent)) {
|
||||
// Update existing key
|
||||
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
||||
} else {
|
||||
// Add new key
|
||||
if (!envContent.includes("# Security Keys")) {
|
||||
envContent += "\n# Security Keys (Auto-generated)\n";
|
||||
}
|
||||
envContent += `${key}=${value}\n`;
|
||||
}
|
||||
|
||||
// Write updated content
|
||||
await fs.writeFile(envPath, envContent);
|
||||
|
||||
// Update process.env for current session
|
||||
process.env[key] = value;
|
||||
|
||||
databaseLogger.info(`Environment variable ${key} updated in .env file`, {
|
||||
operation: "env_file_update",
|
||||
key,
|
||||
path: envPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
|
||||
operation: "env_file_update_failed",
|
||||
key
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SystemCrypto };
|
||||
408
src/backend/utils/user-crypto.ts
Normal file
408
src/backend/utils/user-crypto.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import crypto from "crypto";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { settings } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface KEKSalt {
|
||||
salt: string;
|
||||
iterations: number;
|
||||
algorithm: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface EncryptedDEK {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
algorithm: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserSession {
|
||||
dataKey: Buffer; // Store DEK directly, delete just-in-time fantasy
|
||||
lastActivity: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserCrypto - Simple direct user encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - Delete just-in-time fantasy, cache DEK directly
|
||||
* - Reasonable 2-hour timeout, not 5-minute user experience disaster
|
||||
* - Simple working implementation, not theoretically perfect garbage
|
||||
* - Server restart invalidates sessions (this is reasonable)
|
||||
*/
|
||||
class UserCrypto {
|
||||
private static instance: UserCrypto;
|
||||
private userSessions: Map<string, UserSession> = new Map();
|
||||
|
||||
// Configuration constants - reasonable timeout settings
|
||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||
private static readonly KEK_LENGTH = 32;
|
||||
private static readonly DEK_LENGTH = 32;
|
||||
private static readonly SESSION_DURATION = 2 * 60 * 60 * 1000; // 2 hours, reasonable user experience
|
||||
private static readonly MAX_INACTIVITY = 30 * 60 * 1000; // 30 minutes, not 1-minute disaster
|
||||
|
||||
private constructor() {
|
||||
// Reasonable cleanup interval
|
||||
setInterval(() => {
|
||||
this.cleanupExpiredSessions();
|
||||
}, 5 * 60 * 1000); // Clean every 5 minutes, not 30 seconds
|
||||
}
|
||||
|
||||
static getInstance(): UserCrypto {
|
||||
if (!this.instance) {
|
||||
this.instance = new UserCrypto();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration: generate KEK salt and DEK
|
||||
*/
|
||||
async setupUserEncryption(userId: string, password: string): Promise<void> {
|
||||
const kekSalt = await this.generateKEKSalt();
|
||||
await this.storeKEKSalt(userId, kekSalt);
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
||||
await this.storeEncryptedDEK(userId, encryptedDEK);
|
||||
|
||||
// Immediately clean temporary keys
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
databaseLogger.success("User encryption setup completed", {
|
||||
operation: "user_crypto_setup",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User authentication: validate password and cache DEK
|
||||
* Deleted just-in-time fantasy, works directly
|
||||
*/
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
// Validate password and decrypt DEK
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) {
|
||||
KEK.fill(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
KEK.fill(0); // Immediately clean KEK
|
||||
|
||||
// Debug: Check DEK validity
|
||||
if (!DEK || DEK.length === 0) {
|
||||
databaseLogger.error("DEK is empty or invalid after decryption", {
|
||||
operation: "user_crypto_auth_debug",
|
||||
userId,
|
||||
dekLength: DEK ? DEK.length : 0
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create user session, cache DEK directly
|
||||
const now = Date.now();
|
||||
|
||||
// Clean old session
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK), // Create proper Buffer copy
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0); // Clean temporary DEK
|
||||
|
||||
databaseLogger.success("User authenticated and DEK cached", {
|
||||
operation: "user_crypto_auth",
|
||||
userId,
|
||||
duration: UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("User authentication failed", {
|
||||
operation: "user_crypto_auth_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key - simple direct return from cache
|
||||
* Deleted just-in-time derivation garbage
|
||||
*/
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check if session has expired
|
||||
if (now > session.expiresAt) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
databaseLogger.info("User session expired", {
|
||||
operation: "user_session_expired",
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if max inactivity time exceeded
|
||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
databaseLogger.info("User session inactive timeout", {
|
||||
operation: "user_session_inactive",
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last activity time
|
||||
session.lastActivity = now;
|
||||
return session.dataKey;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* User logout: clear session
|
||||
*/
|
||||
logoutUser(userId: string): void {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
session.dataKey.fill(0); // Securely clear key
|
||||
this.userSessions.delete(userId);
|
||||
}
|
||||
databaseLogger.info("User logged out", {
|
||||
operation: "user_crypto_logout",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is unlocked
|
||||
*/
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
||||
try {
|
||||
// Validate old password
|
||||
const isValid = await this.validatePassword(userId, oldPassword);
|
||||
if (!isValid) return false;
|
||||
|
||||
// Get current DEK
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const oldKEK = this.deriveKEK(oldPassword, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) return false;
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
|
||||
|
||||
// Generate new KEK salt and encrypt DEK
|
||||
const newKekSalt = await this.generateKEKSalt();
|
||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
||||
|
||||
// Store new salt and encrypted DEK
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
// Clean all temporary keys
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
// Clean user session, require re-login
|
||||
this.logoutUser(userId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Private methods =====
|
||||
|
||||
private async validatePassword(userId: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) return false;
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
|
||||
// Clean temporary keys
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
const expiredUsers: string[] = [];
|
||||
|
||||
for (const [userId, session] of this.userSessions.entries()) {
|
||||
if (now > session.expiresAt || now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
session.dataKey.fill(0); // Securely clear key
|
||||
expiredUsers.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
expiredUsers.forEach(userId => {
|
||||
this.userSessions.delete(userId);
|
||||
});
|
||||
|
||||
if (expiredUsers.length > 0) {
|
||||
databaseLogger.info(`Cleaned up ${expiredUsers.length} expired sessions`, {
|
||||
operation: "session_cleanup",
|
||||
count: expiredUsers.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Database operations and encryption methods (simplified version) =====
|
||||
|
||||
private async generateKEKSalt(): Promise<KEKSalt> {
|
||||
return {
|
||||
salt: crypto.randomBytes(32).toString("hex"),
|
||||
iterations: UserCrypto.PBKDF2_ITERATIONS,
|
||||
algorithm: "pbkdf2-sha256",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private deriveKEK(password: string, kekSalt: KEKSalt): Buffer {
|
||||
return crypto.pbkdf2Sync(
|
||||
password,
|
||||
Buffer.from(kekSalt.salt, "hex"),
|
||||
kekSalt.iterations,
|
||||
UserCrypto.KEK_LENGTH,
|
||||
"sha256"
|
||||
);
|
||||
}
|
||||
|
||||
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
|
||||
|
||||
let encrypted = cipher.update(dek);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
data: encrypted.toString("hex"),
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
algorithm: "aes-256-gcm",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
kek,
|
||||
Buffer.from(encryptedDEK.iv, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
|
||||
let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex"));
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Database operation methods
|
||||
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const value = JSON.stringify(kekSalt);
|
||||
|
||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
||||
try {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async storeEncryptedDEK(userId: string, encryptedDEK: EncryptedDEK): Promise<void> {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
|
||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
250
src/backend/utils/user-data-export.ts
Normal file
250
src/backend/utils/user-data-export.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
interface UserExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
userData: {
|
||||
sshHosts: any[];
|
||||
sshCredentials: any[];
|
||||
fileManagerData: {
|
||||
recent: any[];
|
||||
pinned: any[];
|
||||
shortcuts: any[];
|
||||
};
|
||||
dismissedAlerts: any[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
encrypted: boolean;
|
||||
exportType: 'user_data' | 'system_config' | 'all';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataExport - User-level data import/export
|
||||
*
|
||||
* Linus principles:
|
||||
* - Users own their data and should be able to export freely
|
||||
* - Simple and direct, no complex permission checks
|
||||
* - Support both encrypted and plaintext formats
|
||||
* - Don't break existing system architecture
|
||||
*/
|
||||
class UserDataExport {
|
||||
private static readonly EXPORT_VERSION = "v2.0";
|
||||
|
||||
/**
|
||||
* Export user data
|
||||
*/
|
||||
static async exportUserData(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
includeCredentials?: boolean;
|
||||
} = {}
|
||||
): Promise<UserExportData> {
|
||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data export", {
|
||||
operation: "user_data_export",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
// Verify user exists
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Get user data key (if decryption needed)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (format === 'plaintext') {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked - password required for plaintext export");
|
||||
}
|
||||
}
|
||||
|
||||
// Export SSH host configurations
|
||||
const sshHosts = await getDb().select().from(sshData).where(eq(sshData.userId, userId));
|
||||
const processedSshHosts = format === 'plaintext' && userDataKey
|
||||
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
|
||||
: sshHosts;
|
||||
|
||||
// Export SSH credentials (if included)
|
||||
let sshCredentialsData: any[] = [];
|
||||
if (includeCredentials) {
|
||||
const credentials = await getDb().select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
sshCredentialsData = format === 'plaintext' && userDataKey
|
||||
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
|
||||
: credentials;
|
||||
}
|
||||
|
||||
// Export file manager data
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
getDb().select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb().select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb().select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
|
||||
]);
|
||||
|
||||
// Export dismissed alerts
|
||||
const alerts = await getDb().select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
// Build export data
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
userData: {
|
||||
sshHosts: processedSshHosts,
|
||||
sshCredentials: sshCredentialsData,
|
||||
fileManagerData: {
|
||||
recent: recentFiles,
|
||||
pinned: pinnedFiles,
|
||||
shortcuts: shortcuts,
|
||||
},
|
||||
dismissedAlerts: alerts,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
|
||||
encrypted: format === 'encrypted',
|
||||
exportType: scope,
|
||||
},
|
||||
};
|
||||
|
||||
databaseLogger.success("User data export completed", {
|
||||
operation: "user_data_export_complete",
|
||||
userId,
|
||||
totalRecords: exportData.metadata.totalRecords,
|
||||
format,
|
||||
sshHosts: processedSshHosts.length,
|
||||
sshCredentials: sshCredentialsData.length,
|
||||
});
|
||||
|
||||
return exportData;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data export failed", error, {
|
||||
operation: "user_data_export_failed",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as JSON string
|
||||
*/
|
||||
static async exportUserDataToJSON(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
includeCredentials?: boolean;
|
||||
pretty?: boolean;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const { pretty = true } = options;
|
||||
const exportData = await this.exportUserData(userId, options);
|
||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate export data format
|
||||
*/
|
||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
errors.push("Export data must be an object");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (!data.version) {
|
||||
errors.push("Missing version field");
|
||||
}
|
||||
|
||||
if (!data.userId) {
|
||||
errors.push("Missing userId field");
|
||||
}
|
||||
|
||||
if (!data.userData || typeof data.userData !== 'object') {
|
||||
errors.push("Missing or invalid userData field");
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== 'object') {
|
||||
errors.push("Missing or invalid metadata field");
|
||||
}
|
||||
|
||||
// Check required data fields
|
||||
if (data.userData) {
|
||||
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
|
||||
for (const field of requiredFields) {
|
||||
if (!Array.isArray(data.userData[field]) && !(field === 'fileManagerData' && typeof data.userData[field] === 'object')) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
|
||||
const fmFields = ['recent', 'pinned', 'shortcuts'];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export data statistics
|
||||
*/
|
||||
static getExportStats(data: UserExportData): {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
username: string;
|
||||
totalRecords: number;
|
||||
breakdown: {
|
||||
sshHosts: number;
|
||||
sshCredentials: number;
|
||||
fileManagerItems: number;
|
||||
dismissedAlerts: number;
|
||||
};
|
||||
encrypted: boolean;
|
||||
} {
|
||||
return {
|
||||
version: data.version,
|
||||
exportedAt: data.exportedAt,
|
||||
username: data.username,
|
||||
totalRecords: data.metadata.totalRecords,
|
||||
breakdown: {
|
||||
sshHosts: data.userData.sshHosts.length,
|
||||
sshCredentials: data.userData.sshCredentials.length,
|
||||
fileManagerItems: data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataExport, type UserExportData };
|
||||
432
src/backend/utils/user-data-import.ts
Normal file
432
src/backend/utils/user-data-import.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
interface ImportOptions {
|
||||
replaceExisting?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
skipFileManagerData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
summary: {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
fileManagerItemsImported: number;
|
||||
dismissedAlertsImported: number;
|
||||
skippedItems: number;
|
||||
errors: string[];
|
||||
};
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataImport - User data import
|
||||
*
|
||||
* Linus principles:
|
||||
* - Import should not break existing data (unless explicitly requested)
|
||||
* - Support dry-run mode for validation
|
||||
* - Simple strategy for ID conflicts: regenerate
|
||||
* - Error handling must be explicit, no silent failures
|
||||
*/
|
||||
class UserDataImport {
|
||||
|
||||
/**
|
||||
* Import user data
|
||||
*/
|
||||
static async importUserData(
|
||||
targetUserId: string,
|
||||
exportData: UserExportData,
|
||||
options: ImportOptions = {}
|
||||
): Promise<ImportResult> {
|
||||
const {
|
||||
replaceExisting = false,
|
||||
skipCredentials = false,
|
||||
skipFileManagerData = false,
|
||||
dryRun = false
|
||||
} = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data import", {
|
||||
operation: "user_data_import",
|
||||
targetUserId,
|
||||
sourceUserId: exportData.userId,
|
||||
sourceUsername: exportData.username,
|
||||
dryRun,
|
||||
replaceExisting,
|
||||
skipCredentials,
|
||||
skipFileManagerData,
|
||||
});
|
||||
|
||||
// Verify target user exists
|
||||
const targetUser = await getDb().select().from(users).where(eq(users.id, targetUserId));
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
throw new Error(`Target user not found: ${targetUserId}`);
|
||||
}
|
||||
|
||||
// Validate export data format
|
||||
const validation = UserDataExport.validateExportData(exportData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Verify user data is unlocked (if data is encrypted)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (exportData.metadata.encrypted) {
|
||||
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("Target user data not unlocked - password required for encrypted import");
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
summary: {
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
fileManagerItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
skippedItems: 0,
|
||||
errors: [],
|
||||
},
|
||||
dryRun,
|
||||
};
|
||||
|
||||
// Import SSH host configurations
|
||||
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
|
||||
const importStats = await this.importSshHosts(
|
||||
targetUserId,
|
||||
exportData.userData.sshHosts,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
);
|
||||
result.summary.sshHostsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import SSH credentials
|
||||
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
|
||||
const importStats = await this.importSshCredentials(
|
||||
targetUserId,
|
||||
exportData.userData.sshCredentials,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
);
|
||||
result.summary.sshCredentialsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import file manager data
|
||||
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
||||
const importStats = await this.importFileManagerData(
|
||||
targetUserId,
|
||||
exportData.userData.fileManagerData,
|
||||
{ replaceExisting, dryRun }
|
||||
);
|
||||
result.summary.fileManagerItemsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import dismissed alerts
|
||||
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
|
||||
const importStats = await this.importDismissedAlerts(
|
||||
targetUserId,
|
||||
exportData.userData.dismissedAlerts,
|
||||
{ replaceExisting, dryRun }
|
||||
);
|
||||
result.summary.dismissedAlertsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
result.success = result.summary.errors.length === 0;
|
||||
|
||||
databaseLogger.success("User data import completed", {
|
||||
operation: "user_data_import_complete",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
...result.summary,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data import failed", error, {
|
||||
operation: "user_data_import_failed",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SSH host configurations
|
||||
*/
|
||||
private static async importSshHosts(
|
||||
targetUserId: string,
|
||||
sshHosts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const host of sshHosts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate temporary ID for encryption context, then remove for database insert
|
||||
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newHostData = {
|
||||
...host,
|
||||
id: tempId, // Temporary ID for encryption context
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// If data needs re-encryption
|
||||
let processedHostData = newHostData;
|
||||
if (options.userDataKey) {
|
||||
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
|
||||
}
|
||||
|
||||
// Remove temp ID to let database auto-generate real ID
|
||||
delete processedHostData.id;
|
||||
|
||||
await getDb().insert(sshData).values(processedHostData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SSH credentials
|
||||
*/
|
||||
private static async importSshCredentials(
|
||||
targetUserId: string,
|
||||
credentials: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate temporary ID for encryption context, then remove for database insert
|
||||
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newCredentialData = {
|
||||
...credential,
|
||||
id: tempCredId, // Temporary ID for encryption context
|
||||
userId: targetUserId,
|
||||
usageCount: 0, // Reset usage count
|
||||
lastUsed: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// If data needs re-encryption
|
||||
let processedCredentialData = newCredentialData;
|
||||
if (options.userDataKey) {
|
||||
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
|
||||
}
|
||||
|
||||
// Remove temp ID to let database auto-generate real ID
|
||||
delete processedCredentialData.id;
|
||||
|
||||
await getDb().insert(sshCredentials).values(processedCredentialData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import file manager data
|
||||
*/
|
||||
private static async importFileManagerData(
|
||||
targetUserId: string,
|
||||
fileManagerData: any,
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Import recent files
|
||||
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
||||
for (const item of fileManagerData.recent) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerRecent).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Recent file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import pinned files
|
||||
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
||||
for (const item of fileManagerData.pinned) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerPinned).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Pinned file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import shortcuts
|
||||
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
|
||||
for (const item of fileManagerData.shortcuts) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerShortcuts).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Shortcut import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`File manager data import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import dismissed alerts
|
||||
*/
|
||||
private static async importDismissedAlerts(
|
||||
targetUserId: string,
|
||||
alerts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const alert of alerts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if alert already exists
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, targetUserId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0 && !options.replaceExisting) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAlert = {
|
||||
...alert,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing.length > 0 && options.replaceExisting) {
|
||||
await getDb()
|
||||
.update(dismissedAlerts)
|
||||
.set(newAlert)
|
||||
.where(eq(dismissedAlerts.id, existing[0].id));
|
||||
} else {
|
||||
await getDb().insert(dismissedAlerts).values(newAlert);
|
||||
}
|
||||
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Dismissed alert import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from JSON string
|
||||
*/
|
||||
static async importUserDataFromJSON(
|
||||
targetUserId: string,
|
||||
jsonData: string,
|
||||
options: ImportOptions = {}
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const exportData: UserExportData = JSON.parse(jsonData);
|
||||
return await this.importUserData(targetUserId, exportData, options);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error("Invalid JSON format in import data");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataImport, type ImportOptions, type ImportResult };
|
||||
@@ -150,7 +150,10 @@
|
||||
"generateRSA": "Generate RSA",
|
||||
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
|
||||
"failedToGenerateKeyPair": "Failed to generate key pair",
|
||||
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form."
|
||||
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form.",
|
||||
"invalidKey": "Invalid Key",
|
||||
"detectionError": "Detection Error",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH Tools",
|
||||
@@ -191,6 +194,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"minimize": "Minimize",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -376,6 +380,7 @@
|
||||
"overrideUserInfoUrl": "Override User Info URL (not required)",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"keyId": "Key ID",
|
||||
@@ -487,7 +492,11 @@
|
||||
"createBackup": "Create Backup",
|
||||
"exportImport": "Export/Import",
|
||||
"export": "Export",
|
||||
"import": "Import"
|
||||
"import": "Import",
|
||||
"passwordRequired": "Password required",
|
||||
"confirmExport": "Confirm Export",
|
||||
"exportDescription": "Export SSH hosts and credentials as SQLite file",
|
||||
"importDescription": "Import SQLite file with incremental merge (skips duplicates)"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Host Manager",
|
||||
@@ -564,6 +573,8 @@
|
||||
"sshpassRequired": "Sshpass Required For Password Authentication",
|
||||
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
|
||||
"otherInstallMethods": "Other installation methods:",
|
||||
"debianUbuntuEquivalent": "(Debian/Ubuntu) or the equivalent for your OS.",
|
||||
"or": "or",
|
||||
"centosRhelFedora": "CentOS/RHEL/Fedora",
|
||||
"macos": "macOS",
|
||||
"windows": "Windows",
|
||||
@@ -576,8 +587,6 @@
|
||||
"upload": "Upload",
|
||||
"authentication": "Authentication",
|
||||
"password": "Password",
|
||||
"requirePassword": "Require Password",
|
||||
"requirePasswordDescription": "When disabled, sessions can be saved without entering a password",
|
||||
"key": "Key",
|
||||
"credential": "Credential",
|
||||
"selectCredential": "Select Credential",
|
||||
@@ -647,7 +656,10 @@
|
||||
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
||||
"reconnected": "Reconnected successfully",
|
||||
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||
"connectionTimeout": "Connection timeout"
|
||||
"connectionTimeout": "Connection timeout",
|
||||
"terminalTitle": "Terminal - {{host}}",
|
||||
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||
"runTitle": "Running {{command}} - {{host}}"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "File Manager",
|
||||
@@ -655,7 +667,14 @@
|
||||
"folder": "Folder",
|
||||
"connectToSsh": "Connect to SSH to use file operations",
|
||||
"uploadFile": "Upload File",
|
||||
"downloadFile": "Download File",
|
||||
"downloadFile": "Download",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageXOfY": "Page {{current}} of {{total}}",
|
||||
"zoomOut": "Zoom Out",
|
||||
"zoomIn": "Zoom In",
|
||||
"newFile": "New File",
|
||||
"newFolder": "New Folder",
|
||||
"rename": "Rename",
|
||||
@@ -663,7 +682,7 @@
|
||||
"deleteItem": "Delete Item",
|
||||
"currentPath": "Current Path",
|
||||
"uploadFileTitle": "Upload File",
|
||||
"maxFileSize": "Max: 100MB (JSON) / 200MB (Binary)",
|
||||
"maxFileSize": "Max: 1GB (JSON) / 5GB (Binary) - Large files supported",
|
||||
"removeFile": "Remove File",
|
||||
"clickToSelectFile": "Click to select a file",
|
||||
"chooseFile": "Choose File",
|
||||
@@ -722,12 +741,13 @@
|
||||
"properties": "Properties",
|
||||
"preview": "Preview",
|
||||
"refresh": "Refresh",
|
||||
"downloadFiles": "Download {{count}} files",
|
||||
"downloadFiles": "Download {{count}} files to Browser",
|
||||
"copyFiles": "Copy {{count}} items",
|
||||
"cutFiles": "Cut {{count}} items",
|
||||
"deleteFiles": "Delete {{count}} items",
|
||||
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
|
||||
"filesCutToClipboard": "{{count}} items cut to clipboard",
|
||||
"movedItems": "Moved {{count}} items",
|
||||
"failedToDeleteItem": "Failed to delete item",
|
||||
"itemRenamedSuccessfully": "{{type}} renamed successfully",
|
||||
"failedToRenameItem": "Failed to rename item",
|
||||
@@ -793,7 +813,7 @@
|
||||
"dragFilesToWindowToDownload": "Drag files outside window to download",
|
||||
"openTerminalHere": "Open Terminal Here",
|
||||
"run": "Run",
|
||||
"saveToSystem": "Save to System",
|
||||
"saveToSystem": "Save as...",
|
||||
"selectLocationToSave": "Select Location to Save",
|
||||
"openTerminalInFolder": "Open Terminal in This Folder",
|
||||
"openTerminalInFileLocation": "Open Terminal at File Location",
|
||||
@@ -816,12 +836,86 @@
|
||||
"clearAllRecentFiles": "Clear all recent files",
|
||||
"unpinFile": "Unpin file",
|
||||
"removeShortcut": "Remove shortcut",
|
||||
"saveFilesToSystem": "Save {{count}} files to system",
|
||||
"saveToSystem": "Save to system",
|
||||
"saveFilesToSystem": "Save {{count}} files as...",
|
||||
"saveToSystem": "Save as...",
|
||||
"pinFile": "Pin file",
|
||||
"addToShortcuts": "Add to shortcuts",
|
||||
"selectLocationToSave": "Select location to save",
|
||||
"downloadToDefaultLocation": "Download to default location"
|
||||
"downloadToDefaultLocation": "Download to default location",
|
||||
"pasteFailed": "Paste failed",
|
||||
"noUndoableActions": "No undoable actions",
|
||||
"undoCopySuccess": "Undid copy operation: Deleted {{count}} copied files",
|
||||
"undoCopyFailedDelete": "Undo failed: Could not delete any copied files",
|
||||
"undoCopyFailedNoInfo": "Undo failed: Could not find copied file information",
|
||||
"undoMoveSuccess": "Undid move operation: Moved {{count}} files back to original location",
|
||||
"undoMoveFailedMove": "Undo failed: Could not move any files back",
|
||||
"undoMoveFailedNoInfo": "Undo failed: Could not find moved file information",
|
||||
"undoDeleteNotSupported": "Delete operation cannot be undone: Files have been permanently deleted from server",
|
||||
"undoTypeNotSupported": "Unsupported undo operation type",
|
||||
"undoOperationFailed": "Undo operation failed",
|
||||
"unknownError": "Unknown error",
|
||||
"enterPath": "Enter path...",
|
||||
"editPath": "Edit path",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"folderName": "Folder name",
|
||||
"find": "Find...",
|
||||
"replaceWith": "Replace with...",
|
||||
"replace": "Replace",
|
||||
"replaceAll": "Replace All",
|
||||
"downloadInstead": "Download Instead",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"searchAndReplace": "Search & Replace",
|
||||
"editing": "Editing",
|
||||
"navigation": "Navigation",
|
||||
"code": "Code",
|
||||
"search": "Search",
|
||||
"findNext": "Find Next",
|
||||
"findPrevious": "Find Previous",
|
||||
"save": "Save",
|
||||
"selectAll": "Select All",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"goToLine": "Go to Line",
|
||||
"moveLineUp": "Move Line Up",
|
||||
"moveLineDown": "Move Line Down",
|
||||
"toggleComment": "Toggle Comment",
|
||||
"indent": "Indent",
|
||||
"outdent": "Outdent",
|
||||
"autoComplete": "Auto Complete",
|
||||
"imageLoadError": "Failed to load image",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"rotate": "Rotate",
|
||||
"originalSize": "Original Size",
|
||||
"startTyping": "Start typing...",
|
||||
"unknownSize": "Unknown size",
|
||||
"fileIsEmpty": "File is empty",
|
||||
"modified": "Modified",
|
||||
"largeFileWarning": "Large File Warning",
|
||||
"largeFileWarningDesc": "This file is {{size}} in size, which may cause performance issues when opened as text.",
|
||||
"fileNotFoundAndRemoved": "File \"{{name}}\" not found and has been removed from recent/pinned files",
|
||||
"failedToLoadFile": "Failed to load file: {{error}}",
|
||||
"serverErrorOccurred": "Server error occurred. Please try again later.",
|
||||
"fileSavedSuccessfully": "File saved successfully",
|
||||
"autoSaveFailed": "Auto-save failed",
|
||||
"fileAutoSaved": "File auto-saved",
|
||||
"fileDownloadedSuccessfully": "File downloaded successfully",
|
||||
"moveFileFailed": "Failed to move {{name}}",
|
||||
"moveOperationFailed": "Move operation failed",
|
||||
"canOnlyCompareFiles": "Can only compare two files",
|
||||
"comparingFiles": "Comparing files: {{file1}} and {{file2}}",
|
||||
"dragFailed": "Drag operation failed",
|
||||
"filePinnedSuccessfully": "File \"{{name}}\" pinned successfully",
|
||||
"pinFileFailed": "Failed to pin file",
|
||||
"fileUnpinnedSuccessfully": "File \"{{name}}\" unpinned successfully",
|
||||
"unpinFileFailed": "Failed to unpin file",
|
||||
"shortcutAddedSuccessfully": "Folder shortcut \"{{name}}\" added successfully",
|
||||
"addShortcutFailed": "Failed to add shortcut",
|
||||
"operationCompletedSuccessfully": "{{operation}} {{count}} items successfully",
|
||||
"operationCompleted": "{{operation}} {{count}} items",
|
||||
"downloadFileSuccess": "File {{name}} downloaded successfully",
|
||||
"downloadFileFailed": "Download failed"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH Tunnels",
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
"generateRSA": "生成 RSA",
|
||||
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
|
||||
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。"
|
||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
|
||||
"invalidKey": "无效密钥",
|
||||
"detectionError": "检测错误",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH 工具",
|
||||
@@ -190,6 +193,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
"minimize": "最小化",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"maintenance": "维护中",
|
||||
@@ -362,6 +366,7 @@
|
||||
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)",
|
||||
"databaseSecurity": "数据库安全",
|
||||
"encryptionStatus": "加密状态",
|
||||
"encryptionEnabled": "加密已启用",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"keyId": "密钥 ID",
|
||||
@@ -473,7 +478,11 @@
|
||||
"createBackup": "创建备份",
|
||||
"exportImport": "导出/导入",
|
||||
"export": "导出",
|
||||
"import": "导入"
|
||||
"import": "导入",
|
||||
"passwordRequired": "密码为必填项",
|
||||
"confirmExport": "确认导出",
|
||||
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
|
||||
"importDescription": "导入SQLite文件并进行增量合并(跳过重复项)"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "主机管理",
|
||||
@@ -576,8 +585,6 @@
|
||||
"upload": "上传",
|
||||
"authentication": "认证方式",
|
||||
"password": "密码",
|
||||
"requirePassword": "要求密码",
|
||||
"requirePasswordDescription": "禁用时,可以在不输入密码的情况下保存会话",
|
||||
"key": "密钥",
|
||||
"credential": "凭证",
|
||||
"selectCredential": "选择凭证",
|
||||
@@ -588,11 +595,21 @@
|
||||
"maxRetriesDescription": "隧道连接的最大重试次数。",
|
||||
"retryIntervalDescription": "重试尝试之间的等待时间。",
|
||||
"otherInstallMethods": "其他安装方法:",
|
||||
"debianUbuntuEquivalent": "(Debian/Ubuntu) 或您的操作系统的等效命令。",
|
||||
"or": "或",
|
||||
"centosRhelFedora": "CentOS/RHEL/Fedora",
|
||||
"macos": "macOS",
|
||||
"windows": "Windows",
|
||||
"sshpassOSInstructions": {
|
||||
"centos": "CentOS/RHEL/Fedora: sudo yum install sshpass 或 sudo dnf install sshpass",
|
||||
"macos": "macOS: brew install hudochenkov/sshpass/sshpass",
|
||||
"windows": "Windows: 使用 WSL 或考虑使用 SSH 密钥认证"
|
||||
},
|
||||
"sshServerConfigRequired": "SSH 服务器配置要求",
|
||||
"sshServerConfigDesc": "对于隧道连接,SSH 服务器必须配置允许端口转发:",
|
||||
"gatewayPortsYes": "绑定远程端口到所有接口",
|
||||
"allowTcpForwardingYes": "启用端口转发",
|
||||
"permitRootLoginYes": "如果使用 root 用户进行隧道连接",
|
||||
"sshServerConfigReverse": "对于反向 SSH 隧道,端点 SSH 服务器必须允许:",
|
||||
"gatewayPorts": "GatewayPorts yes(绑定远程端口)",
|
||||
"allowTcpForwarding": "AllowTcpForwarding yes(端口转发)",
|
||||
@@ -635,6 +652,9 @@
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
"terminalTitle": "终端 - {{host}}",
|
||||
"terminalWithPath": "终端 - {{host}}:{{path}}",
|
||||
"runTitle": "运行 {{command}} - {{host}}",
|
||||
"connect": "连接主机",
|
||||
"disconnect": "断开连接",
|
||||
"clear": "清屏",
|
||||
@@ -670,7 +690,14 @@
|
||||
"folder": "文件夹",
|
||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||
"uploadFile": "上传文件",
|
||||
"downloadFile": "下载文件",
|
||||
"downloadFile": "下载",
|
||||
"edit": "编辑",
|
||||
"preview": "预览",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"pageXOfY": "第 {{current}} 页,共 {{total}} 页",
|
||||
"zoomOut": "缩小",
|
||||
"zoomIn": "放大",
|
||||
"newFile": "新建文件",
|
||||
"newFolder": "新建文件夹",
|
||||
"rename": "重命名",
|
||||
@@ -678,7 +705,7 @@
|
||||
"deleteItem": "删除项目",
|
||||
"currentPath": "当前路径",
|
||||
"uploadFileTitle": "上传文件",
|
||||
"maxFileSize": "最大:100MB(JSON)/ 200MB(二进制)",
|
||||
"maxFileSize": "最大:1GB(JSON)/ 5GB(二进制)- 支持大文件",
|
||||
"removeFile": "移除文件",
|
||||
"clickToSelectFile": "点击选择文件",
|
||||
"chooseFile": "选择文件",
|
||||
@@ -743,6 +770,15 @@
|
||||
"deleteFiles": "删除 {{count}} 个项目",
|
||||
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
|
||||
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
|
||||
"movedItems": "已移动 {{count}} 个项目",
|
||||
"unknownSize": "未知大小",
|
||||
"fileIsEmpty": "文件为空",
|
||||
"modified": "修改时间",
|
||||
"largeFileWarning": "大文件警告",
|
||||
"largeFileWarningDesc": "此文件大小为 {{size}},以文本形式打开可能会导致性能问题。",
|
||||
"fileNotFoundAndRemoved": "文件 \"{{name}}\" 未找到,已从最近访问/固定文件中移除",
|
||||
"failedToLoadFile": "加载文件失败:{{error}}",
|
||||
"serverErrorOccurred": "服务器错误,请稍后重试。",
|
||||
"failedToDeleteItem": "删除项目失败",
|
||||
"itemRenamedSuccessfully": "{{type}}重命名成功",
|
||||
"failedToRenameItem": "重命名项目失败",
|
||||
@@ -783,7 +819,7 @@
|
||||
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
|
||||
"openTerminalHere": "在此处打开终端",
|
||||
"run": "运行",
|
||||
"saveToSystem": "保存到系统",
|
||||
"saveToSystem": "另存为...",
|
||||
"selectLocationToSave": "选择位置保存",
|
||||
"openTerminalInFolder": "在此文件夹打开终端",
|
||||
"openTerminalInFileLocation": "在文件位置打开终端",
|
||||
@@ -823,12 +859,78 @@
|
||||
"clearAllRecentFiles": "清除所有最近访问",
|
||||
"unpinFile": "取消固定",
|
||||
"removeShortcut": "移除快捷方式",
|
||||
"saveFilesToSystem": "保存 {{count}} 个文件到系统",
|
||||
"saveToSystem": "保存到系统",
|
||||
"saveFilesToSystem": "另存 {{count}} 个文件为...",
|
||||
"saveToSystem": "另存为...",
|
||||
"pinFile": "固定文件",
|
||||
"addToShortcuts": "添加到快捷方式",
|
||||
"selectLocationToSave": "选择位置保存",
|
||||
"downloadToDefaultLocation": "下载到默认位置"
|
||||
"downloadToDefaultLocation": "下载到默认位置",
|
||||
"pasteFailed": "粘贴失败",
|
||||
"noUndoableActions": "没有可撤销的操作",
|
||||
"undoCopySuccess": "已撤销复制操作:删除了 {{count}} 个复制的文件",
|
||||
"undoCopyFailedDelete": "撤销失败:无法删除任何复制的文件",
|
||||
"undoCopyFailedNoInfo": "撤销失败:找不到复制的文件信息",
|
||||
"undoMoveSuccess": "已撤销移动操作:移回了 {{count}} 个文件到原位置",
|
||||
"undoMoveFailedMove": "撤销失败:无法移回任何文件",
|
||||
"undoMoveFailedNoInfo": "撤销失败:找不到移动的文件信息",
|
||||
"undoDeleteNotSupported": "删除操作无法撤销:文件已从服务器永久删除",
|
||||
"undoTypeNotSupported": "不支持撤销此类操作",
|
||||
"undoOperationFailed": "撤销操作失败",
|
||||
"unknownError": "未知错误",
|
||||
"enterPath": "输入路径...",
|
||||
"editPath": "编辑路径",
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"folderName": "文件夹名",
|
||||
"find": "查找...",
|
||||
"replaceWith": "替换为...",
|
||||
"replace": "替换",
|
||||
"replaceAll": "全部替换",
|
||||
"downloadInstead": "下载文件",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"searchAndReplace": "搜索和替换",
|
||||
"editing": "编辑",
|
||||
"navigation": "导航",
|
||||
"code": "代码",
|
||||
"search": "搜索",
|
||||
"findNext": "查找下一个",
|
||||
"findPrevious": "查找上一个",
|
||||
"save": "保存",
|
||||
"selectAll": "全选",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"goToLine": "跳转到行",
|
||||
"moveLineUp": "向上移动行",
|
||||
"moveLineDown": "向下移动行",
|
||||
"toggleComment": "切换注释",
|
||||
"indent": "增加缩进",
|
||||
"outdent": "减少缩进",
|
||||
"autoComplete": "自动补全",
|
||||
"imageLoadError": "图片加载失败",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"rotate": "旋转",
|
||||
"originalSize": "原始大小",
|
||||
"startTyping": "开始输入...",
|
||||
"fileSavedSuccessfully": "文件保存成功",
|
||||
"autoSaveFailed": "自动保存失败",
|
||||
"fileAutoSaved": "文件已自动保存",
|
||||
"fileDownloadedSuccessfully": "文件下载成功",
|
||||
"moveFileFailed": "移动 {{name}} 失败",
|
||||
"moveOperationFailed": "移动操作失败",
|
||||
"canOnlyCompareFiles": "只能对比两个文件",
|
||||
"comparingFiles": "正在对比文件:{{file1}} 与 {{file2}}",
|
||||
"dragFailed": "拖拽失败",
|
||||
"filePinnedSuccessfully": "文件\"{{name}}\"已固定",
|
||||
"pinFileFailed": "固定文件失败",
|
||||
"fileUnpinnedSuccessfully": "文件\"{{name}}\"已取消固定",
|
||||
"unpinFileFailed": "取消固定失败",
|
||||
"shortcutAddedSuccessfully": "文件夹快捷方式\"{{name}}\"已添加",
|
||||
"addShortcutFailed": "添加快捷方式失败",
|
||||
"operationCompletedSuccessfully": "已{{operation}} {{count}} 个项目",
|
||||
"operationCompleted": "已{{operation}} {{count}} 个项目",
|
||||
"downloadFileSuccess": "文件 {{name}} 下载成功",
|
||||
"downloadFileFailed": "下载失败"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH 隧道",
|
||||
|
||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -18,7 +18,7 @@ export interface ElectronAPI {
|
||||
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
|
||||
// 拖拽API
|
||||
// Drag and drop API
|
||||
createTempFile: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface SSHHost {
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
|
||||
// Autostart plaintext credentials
|
||||
autostartPassword?: string;
|
||||
autostartKey?: string;
|
||||
autostartKeyPassword?: string;
|
||||
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
@@ -101,6 +107,14 @@ export interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
|
||||
// Endpoint host credentials for tunnel authentication
|
||||
endpointPassword?: string;
|
||||
endpointKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
endpointAuthType?: string;
|
||||
endpointKeyType?: string;
|
||||
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
Lock,
|
||||
Download,
|
||||
Upload,
|
||||
HardDrive,
|
||||
FileArchive,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -93,19 +91,16 @@ export function AdminSettings({
|
||||
null,
|
||||
);
|
||||
|
||||
// Database encryption state
|
||||
const [encryptionStatus, setEncryptionStatus] = React.useState<any>(null);
|
||||
const [encryptionLoading, setEncryptionLoading] = React.useState(false);
|
||||
const [migrationLoading, setMigrationLoading] = React.useState(false);
|
||||
const [migrationProgress, setMigrationProgress] = React.useState<string>("");
|
||||
// Simplified security state
|
||||
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
||||
|
||||
// Database migration state
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [backupLoading, setBackupLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
const [exportPath, setExportPath] = React.useState<string>("");
|
||||
const [backupPath, setBackupPath] = React.useState<string>("");
|
||||
const [exportPassword, setExportPassword] = React.useState("");
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
@@ -128,7 +123,6 @@ export function AdminSettings({
|
||||
}
|
||||
});
|
||||
fetchUsers();
|
||||
fetchEncryptionStatus();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -277,111 +271,25 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const fetchEncryptionStatus = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/encryption/status`
|
||||
: "http://localhost:8081/encryption/status";
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEncryptionStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch encryption status:", err);
|
||||
}
|
||||
const checkSecurityStatus = async () => {
|
||||
// New v2-kek-dek system is always initialized
|
||||
setSecurityInitialized(true);
|
||||
};
|
||||
|
||||
const handleInitializeEncryption = async () => {
|
||||
setEncryptionLoading(true);
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/encryption/initialize`
|
||||
: "http://localhost:8081/encryption/initialize";
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
toast.success("Database encryption initialized successfully!");
|
||||
await fetchEncryptionStatus();
|
||||
} else {
|
||||
throw new Error("Failed to initialize encryption");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Failed to initialize encryption");
|
||||
} finally {
|
||||
setEncryptionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrateData = async (dryRun: boolean = false) => {
|
||||
setMigrationLoading(true);
|
||||
setMigrationProgress(
|
||||
dryRun ? t("admin.runningVerification") : t("admin.startingMigration"),
|
||||
);
|
||||
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/encryption/migrate`
|
||||
: "http://localhost:8081/encryption/migrate";
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ dryRun }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (dryRun) {
|
||||
toast.success(t("admin.verificationCompleted"));
|
||||
setMigrationProgress(t("admin.verificationInProgress"));
|
||||
} else {
|
||||
toast.success(t("admin.dataMigrationCompleted"));
|
||||
setMigrationProgress(t("admin.migrationCompleted"));
|
||||
await fetchEncryptionStatus();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Migration failed");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed"),
|
||||
);
|
||||
setMigrationProgress("Failed");
|
||||
} finally {
|
||||
setMigrationLoading(false);
|
||||
setTimeout(() => setMigrationProgress(""), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Database export/import handlers
|
||||
const handleExportDatabase = async () => {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
@@ -395,15 +303,34 @@ export function AdminSettings({
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({ password: exportPassword }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setExportPath(result.exportPath);
|
||||
// Handle file download
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || 'termix-export.sqlite';
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
setExportPassword("");
|
||||
setShowPasswordInput(false);
|
||||
} else {
|
||||
throw new Error("Export failed");
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
@@ -418,6 +345,11 @@ export function AdminSettings({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
@@ -428,7 +360,7 @@ export function AdminSettings({
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
formData.append("backupCurrent", "true");
|
||||
formData.append("password", importPassword);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
@@ -441,16 +373,34 @@ export function AdminSettings({
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
toast.success(t("admin.databaseImportedSuccessfully"));
|
||||
const summary = result.summary;
|
||||
const imported = summary.sshHostsImported + summary.sshCredentialsImported + summary.fileManagerItemsImported + summary.dismissedAlertsImported + (summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0) details.push(`${summary.fileManagerItemsImported} file manager items`);
|
||||
if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(', ')})` : ''}, ${skipped} items skipped`
|
||||
);
|
||||
setImportFile(null);
|
||||
await fetchEncryptionStatus(); // Refresh status
|
||||
setImportPassword("");
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`,
|
||||
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Import failed");
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
@@ -459,36 +409,6 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/backup`
|
||||
: "http://localhost:8081/database/backup";
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setBackupPath(result.backupPath);
|
||||
toast.success(t("admin.encryptedBackupCreatedSuccessfully"));
|
||||
} else {
|
||||
throw new Error("Backup failed");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("admin.backupCreationFailed"));
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
@@ -925,7 +845,7 @@ export function AdminSettings({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
@@ -933,241 +853,112 @@ export function AdminSettings({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{encryptionStatus && (
|
||||
<div className="space-y-4">
|
||||
{/* Status Overview */}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="p-3 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
{encryptionStatus.encryption?.enabled ? (
|
||||
<Lock className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Key className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.encryptionStatus")}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
encryptionStatus.encryption?.enabled
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"
|
||||
}`}
|
||||
>
|
||||
{encryptionStatus.encryption?.enabled
|
||||
? t("admin.enabled")
|
||||
: t("admin.disabled")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.keyProtection")}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
encryptionStatus.encryption?.key?.kekProtected
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"
|
||||
}`}
|
||||
>
|
||||
{encryptionStatus.encryption?.key?.kekProtected
|
||||
? t("admin.active")
|
||||
: t("admin.legacy")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.dataStatus")}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
encryptionStatus.migration?.migrationCompleted
|
||||
? "text-green-500"
|
||||
: encryptionStatus.migration
|
||||
?.migrationRequired
|
||||
? "text-yellow-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{encryptionStatus.migration?.migrationCompleted
|
||||
? t("admin.encrypted")
|
||||
: encryptionStatus.migration?.migrationRequired
|
||||
? t("admin.needsMigration")
|
||||
: t("admin.ready")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Simple status display - read only */}
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
|
||||
<div className="text-xs text-green-500">{t("admin.encryptionEnabled")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{!encryptionStatus.encryption?.key?.hasKey ? (
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-medium">
|
||||
{t("admin.initializeEncryption")}
|
||||
</h4>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleInitializeEncryption}
|
||||
disabled={encryptionLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{encryptionLoading
|
||||
? t("admin.initializing")
|
||||
: t("admin.initialize")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{encryptionStatus.migration?.migrationRequired && (
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-yellow-500" />
|
||||
<h4 className="font-medium">
|
||||
{t("admin.migrateData")}
|
||||
</h4>
|
||||
</div>
|
||||
{migrationProgress && (
|
||||
<div className="text-sm text-blue-600">
|
||||
{migrationProgress}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleMigrateData(true)}
|
||||
disabled={migrationLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{t("admin.test")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMigrateData(false)}
|
||||
disabled={migrationLoading}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{migrationLoading
|
||||
? t("admin.migrating")
|
||||
: t("admin.migrate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-medium">
|
||||
{t("admin.backup")}
|
||||
</h4>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={backupLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{backupLoading
|
||||
? t("admin.creatingBackup")
|
||||
: t("admin.createBackup")}
|
||||
</Button>
|
||||
{backupPath && (
|
||||
<div className="p-2 bg-muted rounded border">
|
||||
<div className="text-xs font-mono break-all">
|
||||
{backupPath}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-medium">
|
||||
{t("admin.exportImport")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: t("admin.export")}
|
||||
</Button>
|
||||
{exportPath && (
|
||||
<div className="p-2 bg-muted rounded border">
|
||||
<div className="text-xs font-mono break-all">
|
||||
{exportPath}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".sqlite,.termix-export.sqlite,.db"
|
||||
onChange={(e) =>
|
||||
setImportFile(e.target.files?.[0] || null)
|
||||
{/* Data management functions - export/import */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-medium">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
{showPasswordInput && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="export-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="export-password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleExportDatabase();
|
||||
}
|
||||
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading || !importFile}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading
|
||||
? t("admin.importing")
|
||||
: t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")
|
||||
}
|
||||
</Button>
|
||||
{showPasswordInput && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordInput(false);
|
||||
setExportPassword("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!encryptionStatus && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-muted-foreground">
|
||||
{t("admin.loadingEncryptionStatus")}
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-medium">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2"
|
||||
/>
|
||||
{importFile && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="import-password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading || !importFile || !importPassword.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
generateKeyPair,
|
||||
} from "@/ui/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type {
|
||||
Credential,
|
||||
CredentialEditorProps,
|
||||
@@ -312,9 +315,9 @@ export function CredentialEditor({
|
||||
"ssh-dss": "DSA (SSH)",
|
||||
"rsa-sha2-256": "RSA-SHA2-256",
|
||||
"rsa-sha2-512": "RSA-SHA2-512",
|
||||
invalid: "Invalid Key",
|
||||
error: "Detection Error",
|
||||
unknown: "Unknown",
|
||||
invalid: t("credentials.invalidKey"),
|
||||
error: t("credentials.detectionError"),
|
||||
unknown: t("credentials.unknown"),
|
||||
};
|
||||
return keyTypeMap[keyType] || keyType;
|
||||
};
|
||||
@@ -908,23 +911,39 @@ export function CredentialEditor({
|
||||
</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"
|
||||
<CodeMirror
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
debouncedKeyDetection(
|
||||
e.target.value,
|
||||
value,
|
||||
form.watch("keyPassword"),
|
||||
);
|
||||
}}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="120px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
{detectedKeyType && (
|
||||
@@ -1062,14 +1081,32 @@ export function CredentialEditor({
|
||||
</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"
|
||||
<CodeMirror
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
debouncedPublicKeyDetection(e.target.value);
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
debouncedPublicKeyDetection(value);
|
||||
}}
|
||||
placeholder={t("placeholders.pastePublicKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="120px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
|
||||
@@ -107,7 +107,7 @@ export function FileManagerContextMenu({
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
// 调整菜单位置避免超出屏幕
|
||||
// Adjust menu position to avoid going off screen
|
||||
const adjustPosition = () => {
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 300;
|
||||
@@ -130,13 +130,13 @@ export function FileManagerContextMenu({
|
||||
|
||||
adjustPosition();
|
||||
|
||||
// 延迟添加事件监听器,避免捕获到触发菜单的那次点击
|
||||
// Delay adding event listeners to avoid capturing the click that triggered the menu
|
||||
let cleanupFn: (() => void) | null = null;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 点击外部关闭菜单
|
||||
// Click outside to close menu
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// 检查点击是否在菜单内部
|
||||
// Check if click is inside menu
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-context-menu]");
|
||||
|
||||
@@ -145,13 +145,13 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// 右键点击关闭菜单(Windows行为)
|
||||
// Right-click to close menu (Windows behavior)
|
||||
const handleRightClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 键盘支持
|
||||
// Keyboard support
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -159,12 +159,12 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口失焦关闭菜单
|
||||
// Close menu on window blur
|
||||
const handleBlur = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 滚动时关闭菜单(Windows行为)
|
||||
// Close menu on scroll (Windows behavior)
|
||||
const handleScroll = () => {
|
||||
onClose();
|
||||
};
|
||||
@@ -175,7 +175,7 @@ export function FileManagerContextMenu({
|
||||
window.addEventListener("blur", handleBlur);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
// 设置清理函数
|
||||
// Set cleanup function
|
||||
cleanupFn = () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
@@ -183,7 +183,7 @@ export function FileManagerContextMenu({
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, 50); // 50ms延迟,确保不会捕获到触发菜单的点击
|
||||
}, 50); // 50ms delay to ensure we don't capture the click that triggered the menu
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -204,13 +204,13 @@ export function FileManagerContextMenu({
|
||||
(f) => f.type === "file" && f.executable,
|
||||
);
|
||||
|
||||
// 构建菜单项
|
||||
// Build menu items
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (isFileContext) {
|
||||
// 文件/文件夹选中时的菜单
|
||||
// Menu when files/folders are selected
|
||||
|
||||
// 打开终端功能 - 支持文件和文件夹
|
||||
// Open terminal function - supports files and folders
|
||||
if (onOpenTerminal) {
|
||||
const targetPath = isSingleFile
|
||||
? files[0].type === "directory"
|
||||
@@ -225,11 +225,11 @@ export function FileManagerContextMenu({
|
||||
? t("fileManager.openTerminalInFolder")
|
||||
: t("fileManager.openTerminalInFileLocation"),
|
||||
action: () => onOpenTerminal(targetPath),
|
||||
shortcut: "Ctrl+T",
|
||||
shortcut: "Ctrl+Shift+T",
|
||||
});
|
||||
}
|
||||
|
||||
// 运行可执行文件功能 - 仅对单个可执行文件显示
|
||||
// Run executable file function - only show for single executable files
|
||||
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
||||
menuItems.push({
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
@@ -239,7 +239,7 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有上述功能)
|
||||
// Add separator (if above functions exist)
|
||||
if (
|
||||
onOpenTerminal ||
|
||||
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
||||
@@ -247,7 +247,7 @@ export function FileManagerContextMenu({
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 预览功能
|
||||
// Preview function
|
||||
if (hasFiles && onPreview) {
|
||||
menuItems.push({
|
||||
icon: <Eye className="w-4 h-4" />,
|
||||
@@ -257,34 +257,19 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 下载功能
|
||||
if (hasFiles && onDownload) {
|
||||
// Download function - unified download that uses best available method
|
||||
if (hasFiles && onDragToDesktop) {
|
||||
menuItems.push({
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.downloadFiles", { count: files.length })
|
||||
: t("fileManager.downloadFile"),
|
||||
action: () => onDownload(files),
|
||||
action: () => onDragToDesktop(),
|
||||
shortcut: "Ctrl+D",
|
||||
});
|
||||
}
|
||||
|
||||
// 拖拽到桌面菜单项(支持浏览器和桌面应用)
|
||||
if (hasFiles && onDragToDesktop) {
|
||||
const isModernBrowser = "showSaveFilePicker" in window;
|
||||
menuItems.push({
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.saveFilesToSystem", { count: files.length })
|
||||
: t("fileManager.saveToSystem"),
|
||||
action: () => onDragToDesktop(),
|
||||
shortcut: isModernBrowser
|
||||
? t("fileManager.selectLocationToSave")
|
||||
: t("fileManager.downloadToDefaultLocation"),
|
||||
});
|
||||
}
|
||||
|
||||
// PIN/UNPIN 功能 - 仅对单个文件显示
|
||||
// PIN/UNPIN function - only show for single files
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
@@ -303,7 +288,7 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文件夹快捷方式 - 仅对单个文件夹显示
|
||||
// Add folder shortcut - only show for single folders
|
||||
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
||||
menuItems.push({
|
||||
icon: <Bookmark className="w-4 h-4" />,
|
||||
@@ -312,9 +297,9 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有上述功能)
|
||||
// Add separator (if above functions exist)
|
||||
if (
|
||||
(hasFiles && (onPreview || onDownload || onDragToDesktop)) ||
|
||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||
(isSingleFile &&
|
||||
files[0].type === "file" &&
|
||||
(onPinFile || onUnpinFile)) ||
|
||||
@@ -323,17 +308,17 @@ export function FileManagerContextMenu({
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 重命名功能
|
||||
// Rename function
|
||||
if (isSingleFile && onRename) {
|
||||
menuItems.push({
|
||||
icon: <Edit3 className="w-4 h-4" />,
|
||||
label: t("fileManager.rename"),
|
||||
action: () => onRename(files[0]),
|
||||
shortcut: "F2",
|
||||
shortcut: "F6",
|
||||
});
|
||||
}
|
||||
|
||||
// 复制功能
|
||||
// Copy function
|
||||
if (onCopy) {
|
||||
menuItems.push({
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
@@ -345,7 +330,7 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 剪切功能
|
||||
// Cut function
|
||||
if (onCut) {
|
||||
menuItems.push({
|
||||
icon: <Scissors className="w-4 h-4" />,
|
||||
@@ -357,12 +342,12 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有编辑功能)
|
||||
// Add separator (if edit functions exist)
|
||||
if ((isSingleFile && onRename) || onCopy || onCut) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 删除功能
|
||||
// Delete function
|
||||
if (onDelete) {
|
||||
menuItems.push({
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
@@ -375,12 +360,12 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有删除功能)
|
||||
// Add separator (if delete function exists)
|
||||
if (onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 属性功能
|
||||
// Properties function
|
||||
if (isSingleFile && onProperties) {
|
||||
menuItems.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
@@ -389,19 +374,19 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 空白区域右键菜单
|
||||
// Empty area right-click menu
|
||||
|
||||
// 在当前目录打开终端
|
||||
// Open terminal in current directory
|
||||
if (onOpenTerminal && currentPath) {
|
||||
menuItems.push({
|
||||
icon: <Terminal className="w-4 h-4" />,
|
||||
label: t("fileManager.openTerminalHere"),
|
||||
action: () => onOpenTerminal(currentPath),
|
||||
shortcut: "Ctrl+T",
|
||||
shortcut: "Ctrl+Shift+T",
|
||||
});
|
||||
}
|
||||
|
||||
// 上传功能
|
||||
// Upload function
|
||||
if (onUpload) {
|
||||
menuItems.push({
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
@@ -411,12 +396,12 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有终端或上传功能)
|
||||
// Add separator (if terminal or upload functions exist)
|
||||
if ((onOpenTerminal && currentPath) || onUpload) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 新建文件夹
|
||||
// New folder
|
||||
if (onNewFolder) {
|
||||
menuItems.push({
|
||||
icon: <FolderPlus className="w-4 h-4" />,
|
||||
@@ -426,7 +411,7 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 新建文件
|
||||
// New file
|
||||
if (onNewFile) {
|
||||
menuItems.push({
|
||||
icon: <FilePlus className="w-4 h-4" />,
|
||||
@@ -436,22 +421,22 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加分隔符(如果有新建功能)
|
||||
// Add separator (if new functions exist)
|
||||
if (onNewFolder || onNewFile) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// 刷新功能
|
||||
// Refresh function
|
||||
if (onRefresh) {
|
||||
menuItems.push({
|
||||
icon: <RefreshCw className="w-4 h-4" />,
|
||||
label: t("fileManager.refresh"),
|
||||
action: onRefresh,
|
||||
shortcut: "F5",
|
||||
shortcut: "Ctrl+Y",
|
||||
});
|
||||
}
|
||||
|
||||
// 粘贴功能
|
||||
// Paste function
|
||||
if (hasClipboard && onPaste) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="w-4 h-4" />,
|
||||
@@ -462,15 +447,15 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉连续的分隔符
|
||||
// Filter out consecutive separators
|
||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
|
||||
// 如果是分隔符,检查前一个和后一个是否也是分隔符
|
||||
// If it's a separator, check if previous and next are also separators
|
||||
const prevItem = index > 0 ? menuItems[index - 1] : null;
|
||||
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
|
||||
|
||||
// 如果前一个或后一个是分隔符,则过滤掉当前分隔符
|
||||
// If previous or next is a separator, filter out current separator
|
||||
if (prevItem?.separator || nextItem?.separator) {
|
||||
return false;
|
||||
}
|
||||
@@ -478,7 +463,7 @@ export function FileManagerContextMenu({
|
||||
return true;
|
||||
});
|
||||
|
||||
// 移除开头和结尾的分隔符
|
||||
// Remove separators at beginning and end
|
||||
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
return index > 0 && index < filteredMenuItems.length - 1;
|
||||
@@ -486,13 +471,13 @@ export function FileManagerContextMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 透明遮罩层用于捕获点击事件 */}
|
||||
<div className="fixed inset-0 z-40" />
|
||||
{/* Transparent overlay to capture click events */}
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
|
||||
{/* 菜单本体 */}
|
||||
{/* Menu body */}
|
||||
<div
|
||||
data-context-menu
|
||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-50 overflow-hidden"
|
||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
|
||||
@@ -320,16 +320,26 @@ export function FileManagerFileEditor({
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
||||
height: "100%",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-dark-bg) !important",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
className="min-h-full min-w-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,20 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "../../../types/index.js";
|
||||
|
||||
// 格式化文件大小
|
||||
// Linus-style data structure: separate creation intent from actual files
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
type: 'file' | 'directory';
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes?: number): string {
|
||||
// 处理未定义或null的情况
|
||||
// Handle undefined or null cases
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
|
||||
// 0字节的文件显示为 "0 B"
|
||||
// Display 0-byte files as "0 B"
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -42,7 +50,7 @@ function formatFileSize(bytes?: number): string {
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// 对于小于10的数值显示一位小数,大于10的显示整数
|
||||
// Display one decimal place for values less than 10, integers for values greater than 10
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
|
||||
@@ -84,6 +92,11 @@ interface FileManagerGridProps {
|
||||
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
|
||||
onSystemDragStart?: (files: FileItem[]) => void;
|
||||
onSystemDragEnd?: (e: DragEvent) => void;
|
||||
hasClipboard?: boolean;
|
||||
// Linus-style creation intent props
|
||||
createIntent?: CreateIntent | null;
|
||||
onConfirmCreate?: (name: string) => void;
|
||||
onCancelCreate?: () => void;
|
||||
}
|
||||
|
||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
@@ -182,19 +195,25 @@ export function FileManagerGrid({
|
||||
onFileDiff,
|
||||
onSystemDragStart,
|
||||
onSystemDragEnd,
|
||||
hasClipboard,
|
||||
createIntent,
|
||||
onConfirmCreate,
|
||||
onCancelCreate,
|
||||
}: FileManagerGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
// 统一拖拽状态管理
|
||||
|
||||
|
||||
// Unified drag state management
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
type: "none",
|
||||
files: [],
|
||||
counter: 0,
|
||||
});
|
||||
|
||||
// 全局鼠标移动监听 - 用于拖拽tooltip跟随
|
||||
// Global mouse move listener - for drag tooltip following
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (dragState.type === "internal" && dragState.files.length > 0) {
|
||||
@@ -214,11 +233,11 @@ export function FileManagerGrid({
|
||||
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 开始编辑时设置初始名称
|
||||
// Set initial name when starting edit
|
||||
useEffect(() => {
|
||||
if (editingFile) {
|
||||
setEditingName(editingFile.name);
|
||||
// 延迟聚焦以确保DOM已更新
|
||||
// Delay focus to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
editInputRef.current?.focus();
|
||||
editInputRef.current?.select();
|
||||
@@ -226,7 +245,7 @@ export function FileManagerGrid({
|
||||
}
|
||||
}, [editingFile]);
|
||||
|
||||
// 处理编辑确认
|
||||
// Handle edit confirmation
|
||||
const handleEditConfirm = () => {
|
||||
if (
|
||||
editingFile &&
|
||||
@@ -239,13 +258,13 @@ export function FileManagerGrid({
|
||||
onCancelEdit?.();
|
||||
};
|
||||
|
||||
// 处理编辑取消
|
||||
// Handle edit cancellation
|
||||
const handleEditCancel = () => {
|
||||
setEditingName("");
|
||||
onCancelEdit?.();
|
||||
};
|
||||
|
||||
// 处理输入框按键
|
||||
// Handle input key events
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -256,9 +275,9 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// 文件拖拽处理函数
|
||||
// File drag handling function
|
||||
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
|
||||
// 如果拖拽的文件已选中,则拖拽所有选中的文件
|
||||
// If dragged file is selected, drag all selected files
|
||||
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
|
||||
|
||||
setDragState({
|
||||
@@ -268,14 +287,14 @@ export function FileManagerGrid({
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
|
||||
// 设置拖拽数据,添加内部拖拽标识
|
||||
// Set drag data, add internal drag identifier
|
||||
const dragData = {
|
||||
type: "internal_files",
|
||||
files: filesToDrag.map((f) => f.path),
|
||||
};
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
|
||||
// 触发系统级拖拽开始
|
||||
// Trigger system-level drag start
|
||||
onSystemDragStart?.(filesToDrag);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
@@ -284,7 +303,7 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 只有拖拽到不同文件且不是被拖拽的文件时才设置目标
|
||||
// Only set target when dragging to different file and not being dragged file
|
||||
if (
|
||||
dragState.type === "internal" &&
|
||||
!dragState.files.some((f) => f.path === targetFile.path)
|
||||
@@ -298,7 +317,7 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 清除拖拽目标高亮
|
||||
// Clear drag target highlight
|
||||
if (dragState.target?.path === targetFile.path) {
|
||||
setDragState((prev) => ({ ...prev, target: undefined }));
|
||||
}
|
||||
@@ -313,7 +332,7 @@ export function FileManagerGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否拖拽到自身
|
||||
// Check if dragging to self
|
||||
const isDroppingOnSelf = dragState.files.some(
|
||||
(f) => f.path === targetFile.path,
|
||||
);
|
||||
@@ -323,13 +342,13 @@ export function FileManagerGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断拖拽行为:
|
||||
// 1. 文件/文件夹 拖拽到 文件夹 = 移动操作
|
||||
// 2. 单个文件 拖拽到 单个文件 = diff对比
|
||||
// 3. 其他情况 = 无效操作
|
||||
// Determine drag behavior:
|
||||
// 1. File/folder drag to folder = move operation
|
||||
// 2. Single file drag to single file = diff comparison
|
||||
// 3. Other cases = invalid operation
|
||||
|
||||
if (targetFile.type === "directory") {
|
||||
// 移动操作
|
||||
// Move operation
|
||||
console.log(
|
||||
"Moving files to directory:",
|
||||
dragState.files.map((f) => f.name),
|
||||
@@ -342,7 +361,7 @@ export function FileManagerGrid({
|
||||
dragState.files.length === 1 &&
|
||||
dragState.files[0].type === "file"
|
||||
) {
|
||||
// diff对比操作
|
||||
// Diff comparison operation
|
||||
console.log(
|
||||
"Comparing files:",
|
||||
dragState.files[0].name,
|
||||
@@ -351,7 +370,7 @@ export function FileManagerGrid({
|
||||
);
|
||||
onFileDiff?.(dragState.files[0], targetFile);
|
||||
} else {
|
||||
// 无效操作,给用户提示
|
||||
// Invalid operation, notify user
|
||||
console.log("Invalid drag operation");
|
||||
}
|
||||
|
||||
@@ -361,7 +380,7 @@ export function FileManagerGrid({
|
||||
const handleFileDragEnd = (e: React.DragEvent) => {
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
|
||||
// 触发系统级拖拽结束检测
|
||||
// Trigger system-level drag end detection
|
||||
onSystemDragEnd?.(e.nativeEvent);
|
||||
};
|
||||
|
||||
@@ -378,17 +397,17 @@ export function FileManagerGrid({
|
||||
} | null>(null);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// 导航历史管理
|
||||
// Navigation history management
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([
|
||||
currentPath,
|
||||
]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
// 路径编辑状态
|
||||
// Path editing state
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editPathValue, setEditPathValue] = useState(currentPath);
|
||||
|
||||
// 更新导航历史
|
||||
// Update navigation history
|
||||
useEffect(() => {
|
||||
const lastPath = navigationHistory[historyIndex];
|
||||
if (currentPath !== lastPath) {
|
||||
@@ -399,7 +418,7 @@ export function FileManagerGrid({
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
// 导航函数
|
||||
// Navigation functions
|
||||
const goBack = () => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
@@ -427,7 +446,7 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// 路径导航
|
||||
// Path navigation
|
||||
const pathParts = currentPath.split("/").filter(Boolean);
|
||||
const navigateToPath = (index: number) => {
|
||||
if (index === -1) {
|
||||
@@ -438,7 +457,7 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// 路径编辑功能
|
||||
// Path editing functionality
|
||||
const startEditingPath = () => {
|
||||
setEditPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
@@ -452,7 +471,7 @@ export function FileManagerGrid({
|
||||
const confirmEditingPath = () => {
|
||||
const trimmedPath = editPathValue.trim();
|
||||
if (trimmedPath) {
|
||||
// 确保路径以 / 开头
|
||||
// Ensure path starts with /
|
||||
const normalizedPath = trimmedPath.startsWith("/")
|
||||
? trimmedPath
|
||||
: "/" + trimmedPath;
|
||||
@@ -471,24 +490,24 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// 同步editPathValue与currentPath
|
||||
// Sync editPathValue with currentPath
|
||||
useEffect(() => {
|
||||
if (!isEditingPath) {
|
||||
setEditPathValue(currentPath);
|
||||
}
|
||||
}, [currentPath, isEditingPath]);
|
||||
|
||||
// 拖放处理 - 区分内部文件拖拽和外部文件上传
|
||||
// Drag and drop handling - distinguish internal file drag and external file upload
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 检查是否是内部文件拖拽
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (!isInternalDrag) {
|
||||
// 只有外部文件拖拽才显示上传提示
|
||||
// Only show upload prompt for external file drag
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
type: "external",
|
||||
@@ -507,7 +526,7 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 检查是否是内部文件拖拽
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (!isInternalDrag && dragState.type === "external") {
|
||||
@@ -529,11 +548,11 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 检查是否是内部文件拖拽
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (isInternalDrag) {
|
||||
// 更新鼠标位置
|
||||
// Update mouse position
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
@@ -546,15 +565,15 @@ export function FileManagerGrid({
|
||||
[dragState.type],
|
||||
);
|
||||
|
||||
// 滚轮事件处理,确保滚动正常工作
|
||||
// Mouse wheel event handling, ensure scrolling works normally
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// 不阻止默认滚动行为,让浏览器自己处理滚动
|
||||
// Don't prevent default scroll behavior, let browser handle scrolling
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// 框选功能实现
|
||||
// Box selection functionality implementation
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// 只在空白区域开始框选,避免干扰文件点击
|
||||
// Only start box selection in empty area, avoid interfering with file clicks
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -565,7 +584,7 @@ export function FileManagerGrid({
|
||||
setSelectionStart({ x: startX, y: startY });
|
||||
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
||||
|
||||
// 重置刚完成框选的标志,准备新的框选
|
||||
// Reset flag for just completed selection, prepare for new selection
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -584,7 +603,7 @@ export function FileManagerGrid({
|
||||
|
||||
setSelectionRect({ x, y, width, height });
|
||||
|
||||
// 检测与文件项的交集,进行实时选择
|
||||
// Detect intersection with file items, perform real-time selection
|
||||
if (gridRef.current) {
|
||||
const fileElements =
|
||||
gridRef.current.querySelectorAll("[data-file-path]");
|
||||
@@ -594,7 +613,7 @@ export function FileManagerGrid({
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const containerRect = gridRef.current!.getBoundingClientRect();
|
||||
|
||||
// 简化坐标计算 - 直接使用相对于容器的坐标
|
||||
// Simplify coordinate calculation - directly use coordinates relative to container
|
||||
const relativeElementRect = {
|
||||
left: elementRect.left - containerRect.left,
|
||||
top: elementRect.top - containerRect.top,
|
||||
@@ -602,7 +621,7 @@ export function FileManagerGrid({
|
||||
bottom: elementRect.bottom - containerRect.top,
|
||||
};
|
||||
|
||||
// 选择框坐标
|
||||
// Selection box coordinates
|
||||
const selectionBox = {
|
||||
left: x,
|
||||
top: y,
|
||||
@@ -610,7 +629,7 @@ export function FileManagerGrid({
|
||||
bottom: y + height,
|
||||
};
|
||||
|
||||
// 检查是否相交
|
||||
// Check if intersecting
|
||||
const intersects = !(
|
||||
relativeElementRect.right < selectionBox.left ||
|
||||
relativeElementRect.left > selectionBox.right ||
|
||||
@@ -629,7 +648,7 @@ export function FileManagerGrid({
|
||||
|
||||
console.log("Total selected paths:", selectedPaths.length);
|
||||
|
||||
// 更新选中的文件
|
||||
// Update selected files
|
||||
const newSelection = files.filter((file) =>
|
||||
selectedPaths.includes(file.path),
|
||||
);
|
||||
@@ -651,7 +670,7 @@ export function FileManagerGrid({
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
|
||||
// 只有当移动距离足够大时才认为是框选,否则是点击
|
||||
// Only consider as box selection when movement distance is large enough, otherwise it's a click
|
||||
const startPos = selectionStart;
|
||||
if (startPos) {
|
||||
const rect = gridRef.current?.getBoundingClientRect();
|
||||
@@ -663,13 +682,13 @@ export function FileManagerGrid({
|
||||
);
|
||||
|
||||
if (distance > 5) {
|
||||
// 真正的框选,设置标志防止立即清空
|
||||
// Real box selection, set flag to prevent immediate clearing
|
||||
setJustFinishedSelecting(true);
|
||||
setTimeout(() => {
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
} else {
|
||||
// 只是点击,不设置标志,让handleGridClick正常处理
|
||||
// Just a click, don't set flag, let handleGridClick handle normally
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
}
|
||||
@@ -679,7 +698,7 @@ export function FileManagerGrid({
|
||||
[isSelecting, selectionStart],
|
||||
);
|
||||
|
||||
// 全局鼠标事件监听,确保在容器外也能结束框选
|
||||
// Global mouse event listener, ensure box selection can end outside container
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
if (isSelecting) {
|
||||
@@ -687,7 +706,7 @@ export function FileManagerGrid({
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
|
||||
// 全局mouseup说明是拖拽框选,设置标志
|
||||
// Global mouseup indicates drag box selection, set flag
|
||||
setJustFinishedSelecting(true);
|
||||
setTimeout(() => {
|
||||
setJustFinishedSelecting(false);
|
||||
@@ -727,31 +746,28 @@ export function FileManagerGrid({
|
||||
e.stopPropagation();
|
||||
|
||||
if (dragState.type === "internal") {
|
||||
// 内部拖拽到空白区域:触发下载
|
||||
console.log(
|
||||
"Internal drag to empty area detected, triggering download",
|
||||
);
|
||||
if (onDownload && dragState.files.length > 0) {
|
||||
onDownload(dragState.files);
|
||||
}
|
||||
// Internal drag to empty area: just cancel the drag operation
|
||||
console.log("Internal drag to empty area - cancelling drag operation");
|
||||
// Do not trigger download here - system drag end will handle it if truly outside window
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
} else if (dragState.type === "external") {
|
||||
// 外部拖拽:处理文件上传
|
||||
// External drag: handle file upload
|
||||
if (onUpload && e.dataTransfer.files.length > 0) {
|
||||
onUpload(e.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置拖拽状态
|
||||
// Reset drag state
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
},
|
||||
[onUpload, onDownload, dragState],
|
||||
);
|
||||
|
||||
// 文件选择处理
|
||||
// File selection handling
|
||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// 确保网格获得焦点以支持键盘事件
|
||||
// Ensure grid gets focus to support keyboard events
|
||||
if (gridRef.current) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
@@ -764,11 +780,11 @@ export function FileManagerGrid({
|
||||
);
|
||||
|
||||
if (event.detail === 2) {
|
||||
// 双击打开
|
||||
// Double click to open
|
||||
console.log("Double click - opening file");
|
||||
onFileOpen(file);
|
||||
} else {
|
||||
// 单击选择
|
||||
// Single click to select
|
||||
const multiSelect = event.ctrlKey || event.metaKey;
|
||||
const rangeSelect = event.shiftKey;
|
||||
|
||||
@@ -780,7 +796,7 @@ export function FileManagerGrid({
|
||||
);
|
||||
|
||||
if (rangeSelect && selectedFiles.length > 0) {
|
||||
// 范围选择 (Shift+点击)
|
||||
// Range selection (Shift+click)
|
||||
console.log("Range selection");
|
||||
const lastSelected = selectedFiles[selectedFiles.length - 1];
|
||||
const currentIndex = files.findIndex((f) => f.path === file.path);
|
||||
@@ -794,7 +810,7 @@ export function FileManagerGrid({
|
||||
onSelectionChange(rangeFiles);
|
||||
}
|
||||
} else if (multiSelect) {
|
||||
// 多选 (Ctrl+点击)
|
||||
// Multi-selection (Ctrl+click)
|
||||
console.log("Multi selection");
|
||||
const isSelected = selectedFiles.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
@@ -805,21 +821,21 @@ export function FileManagerGrid({
|
||||
onSelectionChange([...selectedFiles, file]);
|
||||
}
|
||||
} else {
|
||||
// 单选
|
||||
// Single selection
|
||||
console.log("Single selection - should select only:", file.name);
|
||||
onSelectionChange([file]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 空白区域点击取消选择
|
||||
// Click empty area to cancel selection
|
||||
const handleGridClick = (event: React.MouseEvent) => {
|
||||
// 确保网格获得焦点以支持键盘事件
|
||||
// Ensure grid gets focus to support keyboard events
|
||||
if (gridRef.current) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
|
||||
// 如果刚完成框选,不要清空选择
|
||||
// If just completed box selection, don't clear selection
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
!isSelecting &&
|
||||
@@ -829,10 +845,10 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘支持
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// 检查是否有输入框或可编辑元素获得焦点,如果有则跳过
|
||||
// Check if input box or editable element has focus, skip if so
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement &&
|
||||
@@ -879,7 +895,7 @@ export function FileManagerGrid({
|
||||
break;
|
||||
case "v":
|
||||
case "V":
|
||||
if ((event.ctrlKey || event.metaKey) && onPaste) {
|
||||
if ((event.ctrlKey || event.metaKey) && onPaste && hasClipboard) {
|
||||
event.preventDefault();
|
||||
onPaste();
|
||||
}
|
||||
@@ -893,19 +909,22 @@ export function FileManagerGrid({
|
||||
break;
|
||||
case "Delete":
|
||||
if (selectedFiles.length > 0 && onDelete) {
|
||||
// 触发删除操作
|
||||
// Trigger delete operation
|
||||
onDelete(selectedFiles);
|
||||
}
|
||||
break;
|
||||
case "F2":
|
||||
if (selectedFiles.length === 1) {
|
||||
// 触发重命名
|
||||
console.log("Rename file:", selectedFiles[0]);
|
||||
case "F6":
|
||||
if (selectedFiles.length === 1 && onStartEdit) {
|
||||
event.preventDefault();
|
||||
onStartEdit(selectedFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "F5":
|
||||
event.preventDefault();
|
||||
onRefresh();
|
||||
case "y":
|
||||
case "Y":
|
||||
if ((event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
onRefresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -937,9 +956,9 @@ export function FileManagerGrid({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
||||
{/* 工具栏和路径导航 */}
|
||||
{/* Toolbar and path navigation */}
|
||||
<div className="flex-shrink-0 border-b border-dark-border">
|
||||
{/* 导航按钮 */}
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||
<button
|
||||
onClick={goBack}
|
||||
@@ -984,10 +1003,10 @@ export function FileManagerGrid({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center px-3 py-2 text-sm">
|
||||
{isEditingPath ? (
|
||||
// 编辑模式:路径输入框
|
||||
// Edit mode: path input box
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1001,24 +1020,24 @@ export function FileManagerGrid({
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-2 py-1 bg-dark-hover border border-dark-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="输入路径..."
|
||||
placeholder={t("fileManager.enterPath")}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={confirmEditingPath}
|
||||
className="px-2 py-1 bg-primary text-primary-foreground rounded text-xs hover:bg-primary/80"
|
||||
>
|
||||
确认
|
||||
{t("fileManager.confirm")}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditingPath}
|
||||
className="px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs hover:bg-secondary/80"
|
||||
>
|
||||
取消
|
||||
{t("fileManager.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// 查看模式:面包屑导航
|
||||
// View mode: breadcrumb navigation
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigateToPath(-1)}
|
||||
@@ -1042,7 +1061,7 @@ export function FileManagerGrid({
|
||||
<button
|
||||
onClick={startEditingPath}
|
||||
className="ml-2 p-1 rounded hover:bg-dark-hover opacity-60 hover:opacity-100"
|
||||
title="编辑路径"
|
||||
title={t("fileManager.editPath")}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -1051,7 +1070,7 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主文件网格 - 滚动区域 */}
|
||||
{/* Main file grid - scroll area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div
|
||||
ref={gridRef}
|
||||
@@ -1072,7 +1091,7 @@ export function FileManagerGrid({
|
||||
onContextMenu={(e) => onContextMenu?.(e)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* 拖拽提示覆盖层 */}
|
||||
{/* Drag hint overlay */}
|
||||
{dragState.type === "external" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
|
||||
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
|
||||
@@ -1087,7 +1106,7 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 ? (
|
||||
{files.length === 0 && !createIntent ? (
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Folder className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
@@ -1108,29 +1127,19 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
{/* Linus-style creation intent UI - pure separation */}
|
||||
{createIntent && (
|
||||
<CreateIntentGridItem
|
||||
intent={createIntent}
|
||||
onConfirm={onConfirmCreate}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedFiles.some(
|
||||
(f) => f.path === file.path,
|
||||
);
|
||||
|
||||
// 详细调试路径比较
|
||||
if (selectedFiles.length > 0) {
|
||||
console.log(`\n=== File: ${file.name} ===`);
|
||||
console.log(`File path: "${file.path}"`);
|
||||
console.log(
|
||||
`Selected files:`,
|
||||
selectedFiles.map((f) => `"${f.path}"`),
|
||||
);
|
||||
console.log(
|
||||
`Path comparison results:`,
|
||||
selectedFiles.map(
|
||||
(f) =>
|
||||
`"${f.path}" === "${file.path}" -> ${f.path === file.path}`,
|
||||
),
|
||||
);
|
||||
console.log(`Final isSelected: ${isSelected}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
@@ -1141,7 +1150,7 @@ export function FileManagerGrid({
|
||||
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
|
||||
isSelected && "bg-primary/20 border-primary",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed",
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
"opacity-50",
|
||||
)}
|
||||
@@ -1159,10 +1168,10 @@ export function FileManagerGrid({
|
||||
onDragEnd={handleFileDragEnd}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* 文件图标 */}
|
||||
{/* File icon */}
|
||||
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
|
||||
|
||||
{/* 文件名 */}
|
||||
{/* File name */}
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{editingFile?.path === file.path ? (
|
||||
<input
|
||||
@@ -1181,15 +1190,8 @@ export function FileManagerGrid({
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="text-xs text-foreground truncate cursor-pointer hover:bg-accent px-1 py-0.5 rounded transition-colors duration-150 w-fit max-w-full text-center"
|
||||
title={`${file.name} (点击重命名)`}
|
||||
onClick={(e) => {
|
||||
// 阻止文件选择事件
|
||||
if (onStartEdit) {
|
||||
e.stopPropagation();
|
||||
onStartEdit(file);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-foreground break-words px-1 py-0.5 rounded text-center leading-tight w-full"
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
@@ -1203,7 +1205,7 @@ export function FileManagerGrid({
|
||||
)}
|
||||
{file.type === "link" && file.linkTarget && (
|
||||
<p
|
||||
className="text-xs text-primary mt-1 truncate max-w-full"
|
||||
className="text-xs text-primary mt-1 break-words w-full leading-tight"
|
||||
title={file.linkTarget}
|
||||
>
|
||||
→ {file.linkTarget}
|
||||
@@ -1216,8 +1218,16 @@ export function FileManagerGrid({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* 列表视图 */
|
||||
/* List view */
|
||||
<div className="space-y-1">
|
||||
{/* Linus-style creation intent UI - list view */}
|
||||
{createIntent && (
|
||||
<CreateIntentListItem
|
||||
intent={createIntent}
|
||||
onConfirm={onConfirmCreate}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedFiles.some(
|
||||
(f) => f.path === file.path,
|
||||
@@ -1233,7 +1243,7 @@ export function FileManagerGrid({
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-primary/20",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed",
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
"opacity-50",
|
||||
)}
|
||||
@@ -1249,12 +1259,12 @@ export function FileManagerGrid({
|
||||
onDrop={(e) => handleFileDrop(e, file)}
|
||||
onDragEnd={handleFileDragEnd}
|
||||
>
|
||||
{/* 文件图标 */}
|
||||
{/* File icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(file, viewMode)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingFile?.path === file.path ? (
|
||||
<input
|
||||
@@ -1273,22 +1283,15 @@ export function FileManagerGrid({
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="text-sm text-foreground truncate cursor-pointer hover:bg-accent px-1 py-0.5 rounded transition-colors duration-150 w-fit max-w-full"
|
||||
title={`${file.name} (点击重命名)`}
|
||||
onClick={(e) => {
|
||||
// 阻止文件选择事件
|
||||
if (onStartEdit) {
|
||||
e.stopPropagation();
|
||||
onStartEdit(file);
|
||||
}
|
||||
}}
|
||||
className="text-sm text-foreground break-words px-1 py-0.5 rounded leading-tight"
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
)}
|
||||
{file.type === "link" && file.linkTarget && (
|
||||
<p
|
||||
className="text-xs text-primary truncate"
|
||||
className="text-xs text-primary break-words leading-tight"
|
||||
title={file.linkTarget}
|
||||
>
|
||||
→ {file.linkTarget}
|
||||
@@ -1301,7 +1304,7 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件大小 */}
|
||||
{/* File size */}
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{file.type === "file" &&
|
||||
file.size !== undefined &&
|
||||
@@ -1312,7 +1315,7 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 权限信息 */}
|
||||
{/* Permission info */}
|
||||
<div className="flex-shrink-0 text-right w-20">
|
||||
{file.permissions && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
@@ -1326,7 +1329,7 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 框选矩形 */}
|
||||
{/* Selection rectangle */}
|
||||
{isSelecting && selectionRect && (
|
||||
<div
|
||||
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
|
||||
@@ -1341,7 +1344,7 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
{/* Status bar */}
|
||||
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("fileManager.itemCount", { count: files.length })}</span>
|
||||
@@ -1353,15 +1356,15 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 拖拽跟随tooltip */}
|
||||
{/* Drag following tooltip */}
|
||||
{dragState.type === "internal" &&
|
||||
dragState.files.length > 0 &&
|
||||
dragState.mousePosition && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none"
|
||||
className="fixed z-[99999] pointer-events-none"
|
||||
style={{
|
||||
left: dragState.mousePosition.x + 16,
|
||||
top: dragState.mousePosition.y - 8,
|
||||
left: dragState.mousePosition.x + 24,
|
||||
top: dragState.mousePosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||
@@ -1370,14 +1373,14 @@ export function FileManagerGrid({
|
||||
<>
|
||||
<Move className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
移动到 {dragState.target.name}
|
||||
Move to {dragState.target.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
与 {dragState.target.name} 进行diff对比
|
||||
Diff compare with {dragState.target.name}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
@@ -1385,7 +1388,7 @@ export function FileManagerGrid({
|
||||
<>
|
||||
<Download className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
拖到窗口外下载 ({dragState.files.length} 个文件)
|
||||
Drag outside window to download ({dragState.files.length} files)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -1395,3 +1398,109 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Linus-style creation intent component: Grid view
|
||||
function CreateIntentGridItem({
|
||||
intent,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
intent: CreateIntent;
|
||||
onConfirm?: (name: string) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [inputName, setInputName] = useState(intent.currentName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm?.(inputName.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group p-3 rounded-lg border-2 border-dashed border-primary bg-primary/10 transition-all">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2">
|
||||
{intent.type === 'directory' ? (
|
||||
<Folder className="w-8 h-8 text-primary" />
|
||||
) : (
|
||||
<File className="w-8 h-8 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputName}
|
||||
onChange={(e) => setInputName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
className="w-full max-w-[120px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
|
||||
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Linus-style creation intent component: List view
|
||||
function CreateIntentListItem({
|
||||
intent,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
intent: CreateIntent;
|
||||
onConfirm?: (name: string) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [inputName, setInputName] = useState(intent.currentName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm?.(inputName.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10 transition-all">
|
||||
<div className="flex-shrink-0">
|
||||
{intent.type === 'directory' ? (
|
||||
<Folder className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<File className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputName}
|
||||
onChange={(e) => setInputName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
|
||||
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,12 +80,12 @@ export function FileManagerOperations({
|
||||
);
|
||||
|
||||
try {
|
||||
// 读取文件内容 - 支持文本和二进制文件
|
||||
// Read file content - support text and binary files
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
// 检查文件类型,决定读取方式
|
||||
// Check file type to determine reading method
|
||||
const isTextFile =
|
||||
uploadFile.type.startsWith("text/") ||
|
||||
uploadFile.type === "application/json" ||
|
||||
|
||||
@@ -38,9 +38,9 @@ interface FileManagerSidebarProps {
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onLoadDirectory?: (path: string) => void;
|
||||
onFileOpen?: (file: SidebarItem) => void; // 新增:处理文件打开
|
||||
onFileOpen?: (file: SidebarItem) => void; // Added: handle file opening
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number; // 用于触发数据刷新
|
||||
refreshTrigger?: number; // Used to trigger data refresh
|
||||
}
|
||||
|
||||
export function FileManagerSidebar({
|
||||
@@ -61,7 +61,7 @@ export function FileManagerSidebar({
|
||||
new Set(["root"]),
|
||||
);
|
||||
|
||||
// 右键菜单状态
|
||||
// Right-click menu state
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -74,12 +74,12 @@ export function FileManagerSidebar({
|
||||
item: null,
|
||||
});
|
||||
|
||||
// 加载快捷功能数据
|
||||
// Load quick access data
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [currentHost, refreshTrigger]);
|
||||
|
||||
// 加载目录树(依赖sshSessionId)
|
||||
// Load directory tree (depends on sshSessionId)
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadDirectoryTree();
|
||||
@@ -90,7 +90,7 @@ export function FileManagerSidebar({
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
// 加载最近访问文件(限制5个)
|
||||
// Load recent files (limit to 5)
|
||||
const recentData = await getRecentFiles(currentHost.id);
|
||||
const recentItems = recentData.slice(0, 5).map((item: any) => ({
|
||||
id: `recent-${item.id}`,
|
||||
@@ -101,7 +101,7 @@ export function FileManagerSidebar({
|
||||
}));
|
||||
setRecentItems(recentItems);
|
||||
|
||||
// 加载固定文件
|
||||
// Load pinned files
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedItems = pinnedData.map((item: any) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
@@ -111,7 +111,7 @@ export function FileManagerSidebar({
|
||||
}));
|
||||
setPinnedItems(pinnedItems);
|
||||
|
||||
// 加载文件夹快捷方式
|
||||
// Load folder shortcuts
|
||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||
const shortcutItems = shortcutData.map((item: any) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
@@ -122,20 +122,20 @@ export function FileManagerSidebar({
|
||||
setShortcuts(shortcutItems);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick access data:", error);
|
||||
// 如果加载失败,保持空数组
|
||||
// If loading fails, keep empty arrays
|
||||
setRecentItems([]);
|
||||
setPinnedItems([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除功能实现
|
||||
// Delete functionality implementation
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
await removeRecentFile(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // 重新加载数据
|
||||
loadQuickAccessData(); // Reload data
|
||||
toast.success(
|
||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||
);
|
||||
@@ -150,7 +150,7 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
await removePinnedFile(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // 重新加载数据
|
||||
loadQuickAccessData(); // Reload data
|
||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin file:", error);
|
||||
@@ -163,7 +163,7 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
await removeFolderShortcut(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // 重新加载数据
|
||||
loadQuickAccessData(); // Reload data
|
||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove shortcut:", error);
|
||||
@@ -175,11 +175,11 @@ export function FileManagerSidebar({
|
||||
if (!currentHost?.id || recentItems.length === 0) return;
|
||||
|
||||
try {
|
||||
// 批量删除所有recent文件
|
||||
// Batch delete all recent files
|
||||
await Promise.all(
|
||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||
);
|
||||
loadQuickAccessData(); // 重新加载数据
|
||||
loadQuickAccessData(); // Reload data
|
||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear recent files:", error);
|
||||
@@ -187,7 +187,7 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// 右键菜单处理
|
||||
// Right-click menu handling
|
||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -204,7 +204,7 @@ export function FileManagerSidebar({
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
// 点击外部关闭菜单
|
||||
// Click outside to close menu
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible) return;
|
||||
|
||||
@@ -223,7 +223,7 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟添加监听器,避免立即触发
|
||||
// Delay adding listeners to avoid immediate trigger
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
@@ -240,10 +240,10 @@ export function FileManagerSidebar({
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
// 加载根目录
|
||||
// Load root directory
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
|
||||
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
|
||||
// listSSHFiles now always returns {files: Array, path: string} format
|
||||
const rootFiles = response.files || [];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
@@ -255,7 +255,7 @@ export function FileManagerSidebar({
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [], // 子目录将按需加载
|
||||
children: [], // Subdirectories will be loaded on demand
|
||||
}));
|
||||
|
||||
setDirectoryTree([
|
||||
@@ -270,7 +270,7 @@ export function FileManagerSidebar({
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to load directory tree:", error);
|
||||
// 如果加载失败,显示简单的根目录
|
||||
// If loading fails, show simple root directory
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
@@ -289,17 +289,17 @@ export function FileManagerSidebar({
|
||||
toggleFolder(item.id, item.path);
|
||||
onPathChange(item.path);
|
||||
} else if (item.type === "recent" || item.type === "pinned") {
|
||||
// 对于文件类型,调用文件打开回调
|
||||
// For file types, call file open callback
|
||||
if (onFileOpen) {
|
||||
onFileOpen(item);
|
||||
} else {
|
||||
// 如果没有文件打开回调,切换到文件所在目录
|
||||
// If no file open callback, switch to file directory
|
||||
const directory =
|
||||
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||
onPathChange(directory);
|
||||
}
|
||||
} else if (item.type === "shortcut") {
|
||||
// 文件夹快捷方式直接切换到目录
|
||||
// Folder shortcuts directly switch to directory
|
||||
onPathChange(item.path);
|
||||
}
|
||||
};
|
||||
@@ -312,12 +312,12 @@ export function FileManagerSidebar({
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
|
||||
// 按需加载子目录
|
||||
// Load subdirectories on demand
|
||||
if (sshSessionId && folderPath && folderPath !== "/") {
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
|
||||
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
|
||||
// listSSHFiles now always returns {files: Array, path: string} format
|
||||
const subFiles = subResponse.files || [];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
@@ -332,7 +332,7 @@ export function FileManagerSidebar({
|
||||
children: [],
|
||||
}));
|
||||
|
||||
// 更新目录树,为当前文件夹添加子目录
|
||||
// Update directory tree, add subdirectories for current folder
|
||||
setDirectoryTree((prevTree) => {
|
||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
|
||||
return items.map((item) => {
|
||||
@@ -370,7 +370,7 @@ export function FileManagerSidebar({
|
||||
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onContextMenu={(e) => {
|
||||
// 只有快捷功能项才需要右键菜单
|
||||
// Only quick access items need right-click menu
|
||||
if (
|
||||
item.type === "recent" ||
|
||||
item.type === "pinned" ||
|
||||
@@ -447,7 +447,7 @@ export function FileManagerSidebar({
|
||||
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
|
||||
{/* 快捷功能区域 */}
|
||||
{/* Quick access area */}
|
||||
{renderSection(
|
||||
t("fileManager.recent"),
|
||||
<Clock className="w-3 h-3" />,
|
||||
@@ -464,7 +464,7 @@ export function FileManagerSidebar({
|
||||
shortcuts,
|
||||
)}
|
||||
|
||||
{/* 目录树 */}
|
||||
{/* Directory tree */}
|
||||
<div
|
||||
className={cn(
|
||||
hasQuickAccessItems && "pt-4 border-t border-dark-border",
|
||||
@@ -482,7 +482,7 @@ export function FileManagerSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{/* Right-click menu */}
|
||||
{contextMenu.isVisible && contextMenu.item && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
RefreshCw,
|
||||
@@ -35,6 +36,7 @@ export function DiffViewer({
|
||||
onDownload1,
|
||||
onDownload2,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content1, setContent1] = useState<string>("");
|
||||
const [content2, setContent2] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -44,7 +46,7 @@ export function DiffViewer({
|
||||
);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
@@ -68,10 +70,10 @@ export function DiffViewer({
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文件内容
|
||||
// Load file contents
|
||||
const loadFileContents = async () => {
|
||||
if (file1.type !== "file" || file2.type !== "file") {
|
||||
setError("只能对比文件类型的项目");
|
||||
setError(t("fileManager.canOnlyCompareFiles"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,10 +81,10 @@ export function DiffViewer({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
// 并行加载两个文件
|
||||
// Load both files in parallel
|
||||
const [response1, response2] = await Promise.all([
|
||||
readSSHFile(sshSessionId, file1.path),
|
||||
readSSHFile(sshSessionId, file2.path),
|
||||
@@ -95,17 +97,23 @@ export function DiffViewer({
|
||||
|
||||
const errorData = error?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
setError(`文件过大: ${errorData.error}`);
|
||||
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
|
||||
} else if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
setError(
|
||||
`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`,
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: sshHost.name,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`加载文件失败: ${error.message || errorData?.error || "未知错误"}`,
|
||||
t("fileManager.loadFileFailed", {
|
||||
error: error.message || errorData?.error || t("fileManager.unknownError")
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -113,7 +121,7 @@ export function DiffViewer({
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文件
|
||||
// Download file
|
||||
const handleDownloadFile = async (file: FileItem) => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
@@ -139,15 +147,15 @@ export function DiffViewer({
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`文件下载成功: ${file.name}`);
|
||||
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download file:", error);
|
||||
toast.error(`下载失败: ${error.message || "未知错误"}`);
|
||||
toast.error(t("fileManager.downloadFileFailed") + ": " + (error.message || t("fileManager.unknownError")));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件语言类型
|
||||
// Get file language type
|
||||
const getFileLanguage = (fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const languageMap: Record<string, string> = {
|
||||
@@ -182,7 +190,7 @@ export function DiffViewer({
|
||||
return languageMap[ext || ""] || "plaintext";
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadFileContents();
|
||||
}, [file1, file2, sshSessionId]);
|
||||
@@ -192,7 +200,7 @@ export function DiffViewer({
|
||||
<div className="h-full flex items-center justify-center bg-dark-bg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">正在加载文件对比...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("fileManager.loadingFileComparison")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +214,7 @@ export function DiffViewer({
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={loadFileContents} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新加载
|
||||
{t("fileManager.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,12 +223,12 @@ export function DiffViewer({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg">
|
||||
{/* 工具栏 */}
|
||||
{/* Toolbar */}
|
||||
<div className="flex-shrink-0 border-b border-dark-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">对比:</span>
|
||||
<span className="text-muted-foreground">{t("fileManager.compare")}:</span>
|
||||
<span className="font-medium text-green-400 mx-2">
|
||||
{file1.name}
|
||||
</span>
|
||||
@@ -230,7 +238,7 @@ export function DiffViewer({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 视图切换 */}
|
||||
{/* View toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -240,10 +248,10 @@ export function DiffViewer({
|
||||
)
|
||||
}
|
||||
>
|
||||
{diffMode === "side-by-side" ? "并排" : "内联"}
|
||||
{diffMode === "side-by-side" ? t("fileManager.sideBySide") : t("fileManager.inline")}
|
||||
</Button>
|
||||
|
||||
{/* 行号切换 */}
|
||||
{/* Line number toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -256,12 +264,12 @@ export function DiffViewer({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
{/* Download buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file1)}
|
||||
title={`下载 ${file1.name}`}
|
||||
title={t("fileManager.downloadFile", { name: file1.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file1.name}
|
||||
@@ -271,13 +279,13 @@ export function DiffViewer({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file2)}
|
||||
title={`下载 ${file2.name}`}
|
||||
title={t("fileManager.downloadFile", { name: file2.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file2.name}
|
||||
</Button>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
{/* Refresh button */}
|
||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -285,7 +293,7 @@ export function DiffViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff编辑器 */}
|
||||
{/* Diff editor */}
|
||||
<div className="flex-1">
|
||||
<DiffEditor
|
||||
original={content1}
|
||||
@@ -314,7 +322,7 @@ export function DiffViewer({
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">初始化编辑器...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("fileManager.initializingEditor")}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { useWindowManager } from "./WindowManager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, SSHHost } from "../../../../types/index.js";
|
||||
|
||||
interface DiffWindowProps {
|
||||
@@ -23,20 +24,17 @@ export function DiffWindow({
|
||||
initialX = 150,
|
||||
initialY = 100,
|
||||
}: DiffWindowProps) {
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// 窗口操作处理
|
||||
// Window operation handling
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMinimize = () => {
|
||||
minimizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
@@ -51,7 +49,7 @@ export function DiffWindow({
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={`文件对比: ${file1.name} ↔ ${file2.name}`}
|
||||
title={t("fileManager.fileComparison", { file1: file1.name, file2: file2.name })}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={1200}
|
||||
@@ -59,7 +57,6 @@ export function DiffWindow({
|
||||
minWidth={800}
|
||||
minHeight={500}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DraggableWindowProps {
|
||||
title: string;
|
||||
@@ -17,6 +18,7 @@ interface DraggableWindowProps {
|
||||
isMaximized?: boolean;
|
||||
zIndex?: number;
|
||||
onFocus?: () => void;
|
||||
targetSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export function DraggableWindow({
|
||||
@@ -34,8 +36,10 @@ export function DraggableWindow({
|
||||
isMaximized = false,
|
||||
zIndex = 1000,
|
||||
onFocus,
|
||||
targetSize,
|
||||
}: DraggableWindowProps) {
|
||||
// 窗口状态
|
||||
const { t } = useTranslation();
|
||||
// Window state
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
const [size, setSize] = useState({
|
||||
width: initialWidth,
|
||||
@@ -45,19 +49,54 @@ export function DraggableWindow({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||
|
||||
// 拖拽开始位置
|
||||
// Drag and resize start positions
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 处理窗口焦点
|
||||
// Handle target size changes for media files
|
||||
useEffect(() => {
|
||||
if (targetSize && !isMaximized) {
|
||||
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
||||
|
||||
// Calculate appropriate window size maintaining aspect ratio
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth); // Add padding for UI
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight); // Add padding for header/footer
|
||||
|
||||
// If still too large, scale down maintaining aspect ratio
|
||||
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||
const widthRatio = maxWidth / newWidth;
|
||||
const heightRatio = maxHeight / newHeight;
|
||||
const scale = Math.min(widthRatio, heightRatio);
|
||||
|
||||
newWidth = Math.floor(newWidth * scale);
|
||||
newHeight = Math.floor(newHeight * scale);
|
||||
}
|
||||
|
||||
// Ensure minimum size
|
||||
newWidth = Math.max(newWidth, minWidth);
|
||||
newHeight = Math.max(newHeight, minHeight);
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
// Center the window
|
||||
setPosition({
|
||||
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
||||
y: Math.max(0, (window.innerHeight - newHeight) / 2)
|
||||
});
|
||||
}
|
||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||
|
||||
// Handle window focus
|
||||
const handleWindowClick = useCallback(() => {
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
// 拖拽处理
|
||||
// Drag handling
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isMaximized) return;
|
||||
@@ -85,7 +124,7 @@ export function DraggableWindow({
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(window.innerHeight - 40, windowStart.y + deltaY),
|
||||
), // 保持标题栏可见
|
||||
), // Keep title bar visible
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,32 +132,45 @@ export function DraggableWindow({
|
||||
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;
|
||||
let newWidth = sizeStart.width;
|
||||
let newHeight = sizeStart.height;
|
||||
let newX = windowStart.x;
|
||||
let newY = windowStart.y;
|
||||
|
||||
// Handle horizontal resizing
|
||||
if (resizeDirection.includes("right")) {
|
||||
newWidth = Math.max(minWidth, windowStart.x + deltaX);
|
||||
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes("left")) {
|
||||
newWidth = Math.max(minWidth, size.width - deltaX);
|
||||
newX = Math.min(
|
||||
windowStart.x + deltaX,
|
||||
position.x + size.width - minWidth,
|
||||
);
|
||||
const widthChange = -deltaX;
|
||||
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
||||
// Only move position if we're actually changing size
|
||||
if (newWidth > minWidth || widthChange > 0) {
|
||||
newX = windowStart.x - (newWidth - sizeStart.width);
|
||||
} else {
|
||||
newX = windowStart.x - (minWidth - sizeStart.width);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical resizing
|
||||
if (resizeDirection.includes("bottom")) {
|
||||
newHeight = Math.max(minHeight, windowStart.y + deltaY);
|
||||
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes("top")) {
|
||||
newHeight = Math.max(minHeight, size.height - deltaY);
|
||||
newY = Math.min(
|
||||
windowStart.y + deltaY,
|
||||
position.y + size.height - minHeight,
|
||||
);
|
||||
const heightChange = -deltaY;
|
||||
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
||||
// Only move position if we're actually changing size
|
||||
if (newHeight > minHeight || heightChange > 0) {
|
||||
newY = windowStart.y - (newHeight - sizeStart.height);
|
||||
} else {
|
||||
newY = windowStart.y - (minHeight - sizeStart.height);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure window stays within viewport
|
||||
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
||||
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
@@ -129,6 +181,7 @@ export function DraggableWindow({
|
||||
isMaximized,
|
||||
dragStart,
|
||||
windowStart,
|
||||
sizeStart,
|
||||
size,
|
||||
position,
|
||||
minWidth,
|
||||
@@ -143,7 +196,7 @@ export function DraggableWindow({
|
||||
setResizeDirection("");
|
||||
}, []);
|
||||
|
||||
// 调整大小处理
|
||||
// Resize handling
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, direction: string) => {
|
||||
if (isMaximized) return;
|
||||
@@ -153,13 +206,14 @@ export function DraggableWindow({
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: size.width, y: size.height });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
setSizeStart({ width: size.width, height: size.height });
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, size, onFocus],
|
||||
[isMaximized, position, size, onFocus],
|
||||
);
|
||||
|
||||
// 全局事件监听
|
||||
// Global event listeners
|
||||
useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
@@ -176,7 +230,7 @@ export function DraggableWindow({
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 双击标题栏最大化/还原
|
||||
// Double-click title bar to maximize/restore
|
||||
const handleTitleDoubleClick = useCallback(() => {
|
||||
onMaximize?.();
|
||||
}, [onMaximize]);
|
||||
@@ -198,7 +252,7 @@ export function DraggableWindow({
|
||||
}}
|
||||
onClick={handleWindowClick}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
{/* Title bar */}
|
||||
<div
|
||||
ref={titleBarRef}
|
||||
className={cn(
|
||||
@@ -221,7 +275,7 @@ export function DraggableWindow({
|
||||
e.stopPropagation();
|
||||
onMinimize();
|
||||
}}
|
||||
title="最小化"
|
||||
title={t("common.minimize")}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -234,7 +288,7 @@ export function DraggableWindow({
|
||||
e.stopPropagation();
|
||||
onMaximize();
|
||||
}}
|
||||
title={isMaximized ? "还原" : "最大化"}
|
||||
title={isMaximized ? t("common.restore") : t("common.maximize")}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
@@ -250,14 +304,14 @@ export function DraggableWindow({
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
title="关闭"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 窗口内容 */}
|
||||
{/* Window content */}
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ height: "calc(100% - 40px)" }}
|
||||
@@ -265,10 +319,10 @@ export function DraggableWindow({
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 调整大小边框 - 只在非最大化时显示 */}
|
||||
{/* Resize borders - only show when not maximized */}
|
||||
{!isMaximized && (
|
||||
<>
|
||||
{/* 边缘调整 */}
|
||||
{/* Edge resize */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||
@@ -286,7 +340,7 @@ export function DraggableWindow({
|
||||
onMouseDown={(e) => handleResizeStart(e, "right")}
|
||||
/>
|
||||
|
||||
{/* 角落调整 */}
|
||||
{/* Corner resize */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -43,7 +44,8 @@ interface FileWindowProps {
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
// readOnly参数已移除,由FileViewer内部根据文件类型决定
|
||||
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
|
||||
// readOnly parameter removed, determined internally by FileViewer based on file type
|
||||
}
|
||||
|
||||
export function FileWindow({
|
||||
@@ -53,35 +55,38 @@ export function FileWindow({
|
||||
sshHost,
|
||||
initialX = 100,
|
||||
initialY = 100,
|
||||
onFileNotFound,
|
||||
}: FileWindowProps) {
|
||||
const {
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
focusWindow,
|
||||
updateWindow,
|
||||
windows,
|
||||
} = useWindowManager();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
// 首先检查SSH连接状态
|
||||
// First check SSH connection status
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
console.log("SSH connection status:", status);
|
||||
|
||||
if (!status.connected) {
|
||||
console.log("SSH not connected, attempting to reconnect...");
|
||||
|
||||
// 重新建立连接
|
||||
// Re-establish connection
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
@@ -99,12 +104,12 @@ export function FileWindow({
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("SSH connection check/reconnect failed:", error);
|
||||
// 即使连接失败也尝试继续,让具体的API调用报错
|
||||
// Even if connection fails, try to continue and let specific API calls handle errors
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文件内容
|
||||
// Load file content
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
@@ -112,23 +117,23 @@ export function FileWindow({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent); // 初始化待保存内容
|
||||
setPendingContent(fileContent); // Initialize pending content
|
||||
|
||||
// 如果文件大小未知,根据内容计算大小
|
||||
// If file size is unknown, calculate size based on content
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
|
||||
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
|
||||
// Determine if editable based on file type: all except media files are editable
|
||||
const mediaExtensions = [
|
||||
// 图片文件
|
||||
// Image files
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
@@ -138,7 +143,7 @@ export function FileWindow({
|
||||
"webp",
|
||||
"tiff",
|
||||
"ico",
|
||||
// 音频文件
|
||||
// Audio files
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
@@ -146,7 +151,7 @@ export function FileWindow({
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
// 视频文件
|
||||
// Video files
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
@@ -155,7 +160,7 @@ export function FileWindow({
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
// 压缩文件
|
||||
// Archive files
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
@@ -163,7 +168,7 @@ export function FileWindow({
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
// 二进制文件
|
||||
// Binary files
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
@@ -173,12 +178,12 @@ export function FileWindow({
|
||||
];
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
// 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑
|
||||
// Only media files and binary files are not editable, all other files are editable
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
// 检查是否是大文件错误
|
||||
// Check if it's a large file error
|
||||
const errorData = error?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
@@ -188,14 +193,38 @@ export function FileWindow({
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
// If connection error, provide more specific error message
|
||||
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"}`,
|
||||
);
|
||||
// Check if file not found (common error messages from cat command)
|
||||
const errorMessage = errorData?.error || error.message || "Unknown error";
|
||||
const isFileNotFound =
|
||||
(error as any).isFileNotFound ||
|
||||
errorData?.fileNotFound ||
|
||||
error.response?.status === 404 ||
|
||||
errorMessage.includes("File not found") ||
|
||||
errorMessage.includes("No such file or directory") ||
|
||||
errorMessage.includes("cannot access") ||
|
||||
errorMessage.includes("not found") ||
|
||||
errorMessage.includes("Resource not found");
|
||||
|
||||
if (isFileNotFound && onFileNotFound) {
|
||||
// Notify parent component about the missing file for cleanup
|
||||
onFileNotFound(file);
|
||||
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
|
||||
|
||||
// Close this window since the file doesn't exist
|
||||
closeWindow(windowId);
|
||||
return; // Exit early to prevent showing empty editor
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToLoadFile", {
|
||||
error: errorMessage.includes("Server error occurred") ?
|
||||
t("fileManager.serverErrorOccurred") :
|
||||
errorMessage
|
||||
}));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -205,29 +234,29 @@ export function FileWindow({
|
||||
loadFileContent();
|
||||
}, [file, sshSessionId, sshHost]);
|
||||
|
||||
// 保存文件
|
||||
// Save file
|
||||
const handleSave = async (newContent: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||
setContent(newContent);
|
||||
setPendingContent(""); // 清除待保存内容
|
||||
setPendingContent(""); // Clear pending content
|
||||
|
||||
// 清除自动保存定时器
|
||||
// Clear auto-save timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
toast.success("File saved successfully");
|
||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
// If it's a connection error, provide more specific error message
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -236,36 +265,36 @@ export function FileWindow({
|
||||
`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(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理内容变更 - 设置1分钟自动保存
|
||||
// Handle content changes - set 1-minute auto-save
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setPendingContent(newContent);
|
||||
|
||||
// 清除之前的定时器
|
||||
// Clear previous timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
// 设置新的1分钟自动保存定时器
|
||||
// Set new 1-minute auto-save timer
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
console.log("Auto-saving file...");
|
||||
await handleSave(newContent);
|
||||
toast.success("File auto-saved");
|
||||
toast.success(t("fileManager.fileAutoSaved"));
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
toast.error("Auto-save failed");
|
||||
toast.error(t("fileManager.autoSaveFailed"));
|
||||
}
|
||||
}, 60000); // 1分钟 = 60000毫秒
|
||||
}, 60000); // 1 minute = 60000 milliseconds
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -274,10 +303,10 @@ export function FileWindow({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 下载文件
|
||||
// Download file
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
// 确保SSH连接有效
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
@@ -303,12 +332,12 @@ export function FileWindow({
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("File downloaded successfully");
|
||||
toast.success(t("fileManager.fileDownloadedSuccessfully"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
// If it's a connection error, provide more specific error message
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -324,15 +353,11 @@ export function FileWindow({
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口操作处理
|
||||
// Window operation handling
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMinimize = () => {
|
||||
minimizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
@@ -341,6 +366,12 @@ export function FileWindow({
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
// Handle media dimensions change
|
||||
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
|
||||
console.log('Media dimensions received:', dimensions);
|
||||
setMediaDimensions(dimensions);
|
||||
};
|
||||
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
@@ -355,21 +386,22 @@ export function FileWindow({
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
targetSize={mediaDimensions}
|
||||
>
|
||||
<FileViewer
|
||||
file={file}
|
||||
content={pendingContent || content}
|
||||
savedContent={content}
|
||||
isLoading={isLoading}
|
||||
isEditable={isEditable} // 移除强制只读模式,由FileViewer内部控制
|
||||
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
|
||||
onContentChange={handleContentChange}
|
||||
onSave={(newContent) => handleSave(newContent)}
|
||||
onDownload={handleDownload}
|
||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow";
|
||||
import { Terminal } from "../../Terminal/Terminal";
|
||||
import { useWindowManager } from "./WindowManager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -34,10 +35,11 @@ export function TerminalWindow({
|
||||
initialY = 150,
|
||||
executeCommand,
|
||||
}: TerminalWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
// 获取当前窗口状态
|
||||
// Get current window state
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
console.warn(`Window with id ${windowId} not found`);
|
||||
@@ -61,10 +63,10 @@ export function TerminalWindow({
|
||||
};
|
||||
|
||||
const terminalTitle = executeCommand
|
||||
? `运行 - ${hostConfig.name}:${executeCommand}`
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
? `终端 - ${hostConfig.name}:${initialPath}`
|
||||
: `终端 - ${hostConfig.name}`;
|
||||
? t("terminal.terminalWithPath", { host: hostConfig.name, path: initialPath })
|
||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
|
||||
@@ -35,13 +35,13 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
const nextZIndex = useRef(1000);
|
||||
const windowCounter = useRef(0);
|
||||
|
||||
// 打开新窗口
|
||||
// Open new window
|
||||
const openWindow = useCallback(
|
||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
|
||||
// 计算偏移位置,避免窗口完全重叠
|
||||
// Calculate offset position to avoid windows completely overlapping
|
||||
const offset = (windows.length % 5) * 30;
|
||||
const adjustedX = windowData.x + offset;
|
||||
const adjustedY = windowData.y + offset;
|
||||
@@ -60,12 +60,12 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
[windows.length],
|
||||
);
|
||||
|
||||
// 关闭窗口
|
||||
// Close window
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
// 最小化窗口
|
||||
// Minimize window
|
||||
const minimizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
@@ -74,7 +74,7 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 最大化/还原窗口
|
||||
// Maximize/restore window
|
||||
const maximizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
@@ -83,7 +83,7 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 聚焦窗口 (置于顶层)
|
||||
// Focus window (bring to top)
|
||||
const focusWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => {
|
||||
const targetWindow = prev.find((w) => w.id === id);
|
||||
@@ -94,7 +94,7 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 更新窗口属性
|
||||
// Update window properties
|
||||
const updateWindow = useCallback(
|
||||
(id: string, updates: Partial<WindowInstance>) => {
|
||||
setWindows((prev) =>
|
||||
@@ -117,7 +117,7 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
return (
|
||||
<WindowManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{/* 渲染所有窗口 */}
|
||||
{/* Render all windows */}
|
||||
<div className="window-container">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id}>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface UseDragAndDropProps {
|
||||
export function useDragAndDrop({
|
||||
onFilesDropped,
|
||||
onError,
|
||||
maxFileSize = 100, // 100MB default
|
||||
maxFileSize = 5120, // 5GB default - much more reasonable
|
||||
allowedTypes = [], // empty means all types allowed
|
||||
}: UseDragAndDropProps) {
|
||||
const [state, setState] = useState<DragAndDropState>({
|
||||
|
||||
@@ -30,9 +30,14 @@ import {
|
||||
getCredentials,
|
||||
getSSHHosts,
|
||||
updateSSHHost,
|
||||
enableAutoStart,
|
||||
disableAutoStart,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -45,7 +50,6 @@ interface SSHHost {
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
requirePassword?: boolean;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
@@ -173,7 +177,6 @@ export function HostManagerEditor({
|
||||
authType: z.enum(["password", "key", "credential"]),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
password: z.string().optional(),
|
||||
requirePassword: z.boolean().default(true),
|
||||
key: z.any().optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z
|
||||
@@ -207,18 +210,7 @@ export function HostManagerEditor({
|
||||
defaultPath: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "password") {
|
||||
if (
|
||||
data.requirePassword &&
|
||||
(!data.password || data.password.trim() === "")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.passwordRequired"),
|
||||
path: ["password"],
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "key") {
|
||||
if (data.authType === "key") {
|
||||
if (
|
||||
!data.key ||
|
||||
(typeof data.key === "string" && data.key.trim() === "")
|
||||
@@ -279,7 +271,6 @@ export function HostManagerEditor({
|
||||
authType: "password" as const,
|
||||
credentialId: null,
|
||||
password: "",
|
||||
requirePassword: true,
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
@@ -336,7 +327,6 @@ export function HostManagerEditor({
|
||||
authType: defaultAuthType as "password" | "key" | "credential",
|
||||
credentialId: null,
|
||||
password: "",
|
||||
requirePassword: cleanedHost.requirePassword ?? true,
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
@@ -372,7 +362,6 @@ export function HostManagerEditor({
|
||||
authType: "password" as const,
|
||||
credentialId: null,
|
||||
password: "",
|
||||
requirePassword: true,
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
@@ -452,22 +441,47 @@ export function HostManagerEditor({
|
||||
submitData.keyType = data.keyType;
|
||||
}
|
||||
|
||||
let savedHost;
|
||||
if (editingHost && editingHost.id) {
|
||||
const updatedHost = await updateSSHHost(editingHost.id, submitData);
|
||||
savedHost = await updateSSHHost(editingHost.id, submitData);
|
||||
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit(updatedHost);
|
||||
}
|
||||
} else {
|
||||
const newHost = await createSSHHost(submitData);
|
||||
savedHost = await createSSHHost(submitData);
|
||||
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit(newHost);
|
||||
// Handle AutoStart plaintext cache management
|
||||
if (savedHost && savedHost.id && data.tunnelConnections) {
|
||||
const hasAutoStartTunnels = data.tunnelConnections.some(tunnel => tunnel.autoStart);
|
||||
|
||||
if (hasAutoStartTunnels) {
|
||||
// User has enabled autoStart on some tunnels
|
||||
// Need to ensure plaintext cache exists for this host
|
||||
try {
|
||||
await enableAutoStart(savedHost.id);
|
||||
console.log(`AutoStart plaintext cache enabled for SSH host ${savedHost.id}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
||||
// Don't fail the whole operation if cache setup fails
|
||||
toast.warning(t("hosts.autoStartEnableFailed", { name: data.name }));
|
||||
}
|
||||
} else {
|
||||
// User has disabled autoStart on all tunnels
|
||||
// Clean up plaintext cache for this host
|
||||
try {
|
||||
await disableAutoStart(savedHost.id);
|
||||
console.log(`AutoStart plaintext cache disabled for SSH host ${savedHost.id}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
||||
// Don't fail the whole operation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit(savedHost);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
form.reset();
|
||||
@@ -879,24 +893,6 @@ export function HostManagerEditor({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requirePassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("hosts.requirePassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.requirePasswordDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
@@ -906,7 +902,6 @@ export function HostManagerEditor({
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.password")}
|
||||
disabled={!form.watch("requirePassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -988,19 +983,33 @@ export function HostManagerEditor({
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<CodeMirror
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value)
|
||||
}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="120px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1149,7 +1158,7 @@ export function HostManagerEditor({
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo apt install sshpass
|
||||
</code>{" "}
|
||||
(Debian/Ubuntu) or the equivalent for your OS.
|
||||
{t("hosts.debianUbuntuEquivalent")}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>{t("hosts.otherInstallMethods")}</strong>
|
||||
@@ -1158,7 +1167,7 @@ export function HostManagerEditor({
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo yum install sshpass
|
||||
</code>{" "}
|
||||
or{" "}
|
||||
{t("hosts.or")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo dnf install sshpass
|
||||
</code>
|
||||
|
||||
@@ -36,6 +36,22 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// DEBUG: Add global JWT test function (only once)
|
||||
if (typeof window !== 'undefined' && !(window as any).testJWT) {
|
||||
(window as any).testJWT = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
console.log("Manual JWT Test:", {
|
||||
isElectron: isElectron(),
|
||||
rawCookie: document.cookie,
|
||||
localStorage: localStorage.getItem("jwt"),
|
||||
getCookieResult: jwt,
|
||||
jwtLength: jwt?.length || 0,
|
||||
jwtFirst20: jwt?.substring(0, 20) || "empty"
|
||||
});
|
||||
return jwt;
|
||||
};
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -47,6 +63,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -54,6 +71,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
@@ -65,6 +83,36 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Monitor authentication state - Linus principle: explicit state management
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
||||
setIsAuthenticated(prev => {
|
||||
if (prev !== isAuth) {
|
||||
console.debug("Auth State Changed:", {
|
||||
from: prev,
|
||||
to: isAuth,
|
||||
jwtPresent: !!jwtToken,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return isAuth;
|
||||
}
|
||||
return prev; // No change, don't trigger re-render
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Reduced frequency - check every 5 seconds instead of every second
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []); // No dependencies - prevent infinite loop
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === "function") {
|
||||
@@ -139,10 +187,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
[terminal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
// Resize handling moved to AppView to avoid conflicts - Linus principle: eliminate duplicate complexity
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
@@ -159,8 +204,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
if (
|
||||
isUnmountingRef.current ||
|
||||
shouldNotReconnectRef.current ||
|
||||
isReconnectingRef.current
|
||||
isReconnectingRef.current ||
|
||||
isConnectingRef.current
|
||||
) {
|
||||
console.debug("Skipping reconnection - already in progress or blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,6 +245,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify authentication before attempting reconnection
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
isReconnectingRef.current = false;
|
||||
setConnectionError("Authentication required for reconnection");
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
@@ -210,14 +266,45 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
// Prevent duplicate connections - Linus principle: fail fast
|
||||
if (isConnectingRef.current) {
|
||||
console.debug("Skipping connection - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const wsUrl = isDev
|
||||
? "ws://localhost:8082"
|
||||
// Get JWT token for WebSocket authentication (from cookie, not localStorage)
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
// DEBUG: Log authentication issues only
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.debug("JWT Debug Info:", {
|
||||
isElectron: isElectron(),
|
||||
rawCookie: isElectron() ? localStorage.getItem("jwt") : document.cookie,
|
||||
jwtToken: jwtToken,
|
||||
isEmpty: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
isConnectingRef.current = false; // Reset on auth failure
|
||||
// Don't show toast here - let auth system handle it
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
@@ -226,9 +313,37 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:8082/`;
|
||||
|
||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||
console.log("Closing existing WebSocket connection before creating new one");
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
// Clear existing intervals/timeouts
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
// DEBUG: Log WebSocket connection details
|
||||
console.log("Creating WebSocket connection:", {
|
||||
baseWsUrl,
|
||||
jwtTokenLength: jwtToken.length,
|
||||
jwtTokenStart: jwtToken.substring(0, 20),
|
||||
encodedTokenLength: encodeURIComponent(jwtToken).length,
|
||||
wsUrl: wsUrl.length > 100 ? `${wsUrl.substring(0, 100)}...` : wsUrl
|
||||
});
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
@@ -324,6 +439,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
} else if (msg.type === "connected") {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
@@ -351,9 +467,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
setConnectionError("Authentication failed - please re-login");
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Show authentication error message
|
||||
toast.error("Authentication failed. Please log in again.");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
if (
|
||||
!wasDisconnectedBySSH.current &&
|
||||
@@ -366,6 +501,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
setConnectionError(t("terminal.websocketError"));
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
@@ -410,6 +546,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
|
||||
if (!isAuthenticated) {
|
||||
console.debug("Terminal setup delayed - waiting for authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
@@ -515,33 +657,55 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 100);
|
||||
}, 150); // Increased debounce for better stability
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
// Show terminal immediately - better UX, no unnecessary delays
|
||||
setVisible(true);
|
||||
|
||||
const readyFonts =
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
|
||||
readyFonts.then(() => {
|
||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
// Verify authentication before attempting WebSocket connection
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
// DEBUG: Log only authentication failures
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.debug("ReadyFonts Auth Check Failed:", {
|
||||
isAuthenticated: isAuthenticated,
|
||||
jwtPresent: !!jwtToken
|
||||
});
|
||||
}
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("WebSocket connection delayed - no authentication token");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
// Don't show toast here - let auth system handle it
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 300);
|
||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -564,7 +728,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getUserInfo,
|
||||
getRegistrationAllowed,
|
||||
getOIDCConfig,
|
||||
getUserCount,
|
||||
getSetupRequired,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
@@ -124,9 +124,9 @@ export function HomepageAuth({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCount()
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (res.count === 0) {
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
@@ -182,6 +182,17 @@ export function HomepageAuth({
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
|
||||
// DEBUG: Verify JWT was set correctly
|
||||
const verifyJWT = getCookie("jwt");
|
||||
console.log("JWT Set Debug:", {
|
||||
originalToken: res.token.substring(0, 20) + "...",
|
||||
retrievedToken: verifyJWT ? verifyJWT.substring(0, 20) + "..." : null,
|
||||
match: res.token === verifyJWT,
|
||||
tokenLength: res.token.length,
|
||||
retrievedLength: verifyJWT?.length || 0
|
||||
});
|
||||
|
||||
[meRes] = await Promise.all([getUserInfo()]);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
|
||||
@@ -11,7 +11,8 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron } from "@/ui/main-axios.ts";
|
||||
import { isElectron, getCookie } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
@@ -31,7 +32,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
@@ -42,6 +48,36 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Monitor authentication state - Linus principle: explicit state management
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
||||
setIsAuthenticated(prev => {
|
||||
if (prev !== isAuth) {
|
||||
console.debug("Mobile Auth State Changed:", {
|
||||
from: prev,
|
||||
to: isAuth,
|
||||
jwtPresent: !!jwtToken,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return isAuth;
|
||||
}
|
||||
return prev; // No change, don't trigger re-render
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Reduced frequency - check every 5 seconds instead of every second
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []); // No dependencies - prevent infinite loop
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === "function") {
|
||||
@@ -103,10 +139,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
[terminal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
// Resize handling optimized to avoid conflicts - Linus principle: eliminate duplicate complexity
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
@@ -141,8 +174,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
else if (msg.type === "error")
|
||||
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
|
||||
else if (msg.type === "connected") {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
terminal.writeln(
|
||||
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
||||
);
|
||||
@@ -150,13 +185,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
ws.addEventListener("close", (event) => {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionClosed")}]`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
|
||||
});
|
||||
}
|
||||
@@ -164,6 +214,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
|
||||
if (!isAuthenticated) {
|
||||
console.debug("Terminal setup delayed - waiting for authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: false,
|
||||
cursorStyle: "bar",
|
||||
@@ -215,7 +271,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 100);
|
||||
}, 150); // Increased debounce for better stability
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
@@ -224,15 +280,26 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
// Show terminal immediately - better UX for mobile
|
||||
setVisible(true);
|
||||
|
||||
readyFonts.then(() => {
|
||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
}, 0);
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
|
||||
// Verify authentication before attempting WebSocket connection
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("WebSocket connection delayed - no authentication token");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
// Don't show toast here - let auth system handle it
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
@@ -243,8 +310,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const wsUrl = isDev
|
||||
? "ws://localhost:8082"
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
@@ -254,16 +321,42 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
// Prevent duplicate connections - Linus principle: fail fast
|
||||
if (isConnectingRef.current) {
|
||||
console.debug("Skipping connection - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||
console.log("Closing existing WebSocket connection before creating new one");
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
// Clear existing ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 300);
|
||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -276,7 +369,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserInfo,
|
||||
getRegistrationAllowed,
|
||||
getOIDCConfig,
|
||||
getUserCount,
|
||||
getSetupRequired,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
@@ -111,9 +111,9 @@ export function HomepageAuth({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCount()
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (res.count === 0) {
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
FileDown,
|
||||
@@ -30,6 +31,8 @@ export function DragIndicator({
|
||||
error,
|
||||
className,
|
||||
}: DragIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
@@ -54,18 +57,22 @@ export function DragIndicator({
|
||||
|
||||
const getStatusText = () => {
|
||||
if (error) {
|
||||
return `错误: ${error}`;
|
||||
return t("dragIndicator.error", { error });
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
return `正在拖拽${fileName ? ` ${fileName}` : ""}到桌面...`;
|
||||
return t("dragIndicator.dragging", { fileName: fileName || "" });
|
||||
}
|
||||
|
||||
if (isDownloading) {
|
||||
return `正在准备拖拽${fileName ? ` ${fileName}` : ""}...`;
|
||||
return t("dragIndicator.preparing", { fileName: fileName || "" });
|
||||
}
|
||||
|
||||
return `准备拖拽${fileCount > 1 ? ` ${fileCount} 个文件` : fileName ? ` ${fileName}` : ""}`;
|
||||
if (fileCount > 1) {
|
||||
return t("dragIndicator.readyMultiple", { count: fileCount });
|
||||
}
|
||||
|
||||
return t("dragIndicator.readySingle", { fileName: fileName || "" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,17 +86,17 @@ export function DragIndicator({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 图标 */}
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 标题 */}
|
||||
{/* Title */}
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{fileCount > 1 ? "批量拖拽到桌面" : "拖拽到桌面"}
|
||||
{fileCount > 1 ? t("dragIndicator.batchDrag") : t("dragIndicator.dragToDesktop")}
|
||||
</div>
|
||||
|
||||
{/* 状态文字 */}
|
||||
{/* Status text */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs mb-3",
|
||||
@@ -103,7 +110,7 @@ export function DragIndicator({
|
||||
{getStatusText()}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{/* Progress bar */}
|
||||
{(isDownloading || isDragging) && !error && (
|
||||
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
|
||||
<div
|
||||
@@ -116,24 +123,24 @@ export function DragIndicator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进度百分比 */}
|
||||
{/* Progress percentage */}
|
||||
{(isDownloading || isDragging) && !error && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{progress.toFixed(0)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽提示 */}
|
||||
{/* Drag hint */}
|
||||
{isDragging && !error && (
|
||||
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
现在可以拖拽到桌面任意位置
|
||||
{t("dragIndicator.canDragAnywhere")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动画效果的背景 */}
|
||||
{/* Background with animation effect */}
|
||||
{isDragging && !error && (
|
||||
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 检查是否在Electron环境中
|
||||
// Check if running in Electron environment
|
||||
const isElectron = () => {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -41,20 +41,20 @@ export function useDragToDesktop({
|
||||
);
|
||||
};
|
||||
|
||||
// 拖拽单个文件到桌面
|
||||
// Drag single file to desktop
|
||||
const dragFileToDesktop = useCallback(
|
||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "拖拽到桌面功能仅在桌面应用中可用";
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.type !== "file") {
|
||||
const error = "只能拖拽文件到桌面";
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -68,16 +68,16 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// 下载文件内容
|
||||
// Download file content
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (!response?.content) {
|
||||
throw new Error("无法获取文件内容");
|
||||
throw new Error("Unable to get file content");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 50 }));
|
||||
|
||||
// 创建临时文件
|
||||
// Create temporary file
|
||||
const tempResult = await window.electronAPI.createTempFile({
|
||||
fileName: file.name,
|
||||
content: response.content,
|
||||
@@ -85,30 +85,30 @@ export function useDragToDesktop({
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(tempResult.error || "创建临时文件失败");
|
||||
throw new Error(tempResult.error || "Failed to create temporary file");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
// 开始拖拽
|
||||
// Start dragging
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "开始拖拽失败");
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`正在拖拽 ${file.name} 到桌面`);
|
||||
toast.success(`Dragging ${file.name} to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// 延迟清理临时文件(给用户时间完成拖拽)
|
||||
// Delayed cleanup of temporary file (give user time to complete drag)
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
@@ -117,12 +117,12 @@ export function useDragToDesktop({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 10000); // 10秒后清理
|
||||
}, 10000); // Cleanup after 10 seconds
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("拖拽到桌面失败:", error);
|
||||
const errorMessage = error.message || "拖拽失败";
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const errorMessage = error.message || "Drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -133,7 +133,7 @@ export function useDragToDesktop({
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`拖拽失败: ${errorMessage}`);
|
||||
toast.error(`Drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
@@ -143,13 +143,13 @@ export function useDragToDesktop({
|
||||
[sshSessionId, sshHost],
|
||||
);
|
||||
|
||||
// 拖拽多个文件到桌面(批量操作)
|
||||
// Drag multiple files to desktop (batch operation)
|
||||
const dragFilesToDesktop = useCallback(
|
||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "拖拽到桌面功能仅在桌面应用中可用";
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -157,7 +157,7 @@ export function useDragToDesktop({
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "没有可拖拽的文件";
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -175,7 +175,7 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// 批量下载文件
|
||||
// Batch download files
|
||||
const downloadPromises = fileList.map((file) =>
|
||||
downloadSSHFile(sshSessionId, file.path),
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export function useDragToDesktop({
|
||||
const responses = await Promise.all(downloadPromises);
|
||||
setState((prev) => ({ ...prev, progress: 40 }));
|
||||
|
||||
// 创建临时文件夹结构
|
||||
// Create temporary folder structure
|
||||
const folderName = `Files_${Date.now()}`;
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
@@ -197,30 +197,30 @@ export function useDragToDesktop({
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(tempResult.error || "创建临时文件夹失败");
|
||||
throw new Error(tempResult.error || "Failed to create temporary folder");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
// 开始拖拽文件夹
|
||||
// Start dragging folder
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: folderName,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "开始拖拽失败");
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`正在拖拽 ${fileList.length} 个文件到桌面`);
|
||||
toast.success(`Dragging ${fileList.length} files to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// 延迟清理临时文件夹
|
||||
// Delayed cleanup of temporary folder
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
@@ -229,12 +229,12 @@ export function useDragToDesktop({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 15000); // 15秒后清理
|
||||
}, 15000); // Cleanup after 15 seconds
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("批量拖拽到桌面失败:", error);
|
||||
const errorMessage = error.message || "批量拖拽失败";
|
||||
console.error("Failed to batch drag to desktop:", error);
|
||||
const errorMessage = error.message || "Batch drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -245,7 +245,7 @@ export function useDragToDesktop({
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`批量拖拽失败: ${errorMessage}`);
|
||||
toast.error(`Batch drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
@@ -255,31 +255,31 @@ export function useDragToDesktop({
|
||||
[sshSessionId, sshHost, dragFileToDesktop],
|
||||
);
|
||||
|
||||
// 拖拽文件夹到桌面
|
||||
// Drag folder to desktop
|
||||
const dragFolderToDesktop = useCallback(
|
||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "拖拽到桌面功能仅在桌面应用中可用";
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (folder.type !== "directory") {
|
||||
const error = "只能拖拽文件夹类型";
|
||||
const error = "Only folder types can be dragged";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableToast) {
|
||||
toast.info("文件夹拖拽功能开发中...");
|
||||
toast.info("Folder drag functionality is under development...");
|
||||
}
|
||||
|
||||
// TODO: 实现文件夹递归下载和拖拽
|
||||
// 这需要额外的API来递归获取文件夹内容
|
||||
// TODO: Implement recursive folder download and drag
|
||||
// This requires additional API to recursively get folder contents
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useDragToSystemDesktop({
|
||||
options: DragToSystemOptions;
|
||||
} | null>(null);
|
||||
|
||||
// 目录记忆功能
|
||||
// Directory memory functionality
|
||||
const getLastSaveDirectory = async () => {
|
||||
try {
|
||||
if ("indexedDB" in window) {
|
||||
@@ -61,7 +61,7 @@ export function useDragToSystemDesktop({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("无法获取上次保存目录:", error);
|
||||
console.log("Unable to get last save directory:", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -79,18 +79,18 @@ export function useDragToSystemDesktop({
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("无法保存目录记录:", error);
|
||||
console.log("Unable to save directory record:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查File System Access API支持
|
||||
// Check File System Access API support
|
||||
const isFileSystemAPISupported = () => {
|
||||
return "showSaveFilePicker" in window;
|
||||
};
|
||||
|
||||
// 检查拖拽是否离开窗口边界
|
||||
// Check if drag has left window boundaries
|
||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||
const margin = 50; // 增加容差边距
|
||||
const margin = 50; // Increase tolerance margin
|
||||
return (
|
||||
e.clientX < margin ||
|
||||
e.clientX > window.innerWidth - margin ||
|
||||
@@ -99,14 +99,14 @@ export function useDragToSystemDesktop({
|
||||
);
|
||||
};
|
||||
|
||||
// 创建文件blob
|
||||
// Create file blob
|
||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
if (!response?.content) {
|
||||
throw new Error(`无法获取文件 ${file.name} 的内容`);
|
||||
throw new Error(`Unable to get content for file ${file.name}`);
|
||||
}
|
||||
|
||||
// base64转换为blob
|
||||
// Convert base64 to blob
|
||||
const binaryString = atob(response.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
@@ -116,9 +116,9 @@ export function useDragToSystemDesktop({
|
||||
return new Blob([bytes]);
|
||||
};
|
||||
|
||||
// 创建ZIP文件(用于多文件下载)
|
||||
// Create ZIP file (for multi-file download)
|
||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||
// 这里需要一个轻量级的zip库,先用简单方案
|
||||
// A lightweight zip library is needed here, using simple approach for now
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
@@ -130,42 +130,8 @@ export function useDragToSystemDesktop({
|
||||
return await zip.generateAsync({ type: "blob" });
|
||||
};
|
||||
|
||||
// 使用File System Access API保存文件
|
||||
const saveFileWithSystemAPI = async (blob: Blob, suggestedName: string) => {
|
||||
try {
|
||||
// 获取上次保存的目录句柄
|
||||
const lastDirHandle = await getLastSaveDirectory();
|
||||
|
||||
const fileHandle = await (window as any).showSaveFilePicker({
|
||||
suggestedName,
|
||||
startIn: lastDirHandle || "desktop", // 优先使用上次目录,否则桌面
|
||||
types: [
|
||||
{
|
||||
description: "文件",
|
||||
accept: {
|
||||
"*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 保存当前目录句柄以便下次使用
|
||||
await saveLastDirectory(fileHandle);
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
return false; // 用户取消
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 降级方案:传统下载
|
||||
// Fallback solution: traditional download
|
||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -177,22 +143,22 @@ export function useDragToSystemDesktop({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 处理拖拽到系统桌面
|
||||
// Handle drag to system desktop
|
||||
const handleDragToSystem = useCallback(
|
||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (files.length === 0) {
|
||||
const error = "没有可拖拽的文件";
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 过滤出文件类型
|
||||
// Filter out file types
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "只能拖拽文件到桌面";
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -206,40 +172,67 @@ export function useDragToSystemDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
let blob: Blob;
|
||||
let fileName: string;
|
||||
// Determine file name first (synchronously)
|
||||
const fileName = fileList.length === 1
|
||||
? fileList[0].name
|
||||
: `files_${Date.now()}.zip`;
|
||||
|
||||
// For File System Access API, get the file handle FIRST to preserve user gesture
|
||||
let fileHandle: any = null;
|
||||
if (isFileSystemAPISupported()) {
|
||||
try {
|
||||
fileHandle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: fileName,
|
||||
startIn: "desktop",
|
||||
types: [
|
||||
{
|
||||
description: "Files",
|
||||
accept: {
|
||||
"*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
// User cancelled
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Now create the blob (after getting file handle)
|
||||
let blob: Blob;
|
||||
if (fileList.length === 1) {
|
||||
// 单文件
|
||||
// Single file
|
||||
blob = await createFileBlob(fileList[0]);
|
||||
fileName = fileList[0].name;
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
} else {
|
||||
// 多文件打包成ZIP
|
||||
// Package multiple files into ZIP
|
||||
blob = await createZipBlob(fileList);
|
||||
fileName = `files_${Date.now()}.zip`;
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 90 }));
|
||||
|
||||
// 优先使用File System Access API
|
||||
if (isFileSystemAPISupported()) {
|
||||
const saved = await saveFileWithSystemAPI(blob, fileName);
|
||||
if (!saved) {
|
||||
// 用户取消了
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
// Save the file
|
||||
if (fileHandle) {
|
||||
// Use File System Access API with pre-obtained handle
|
||||
await saveLastDirectory(fileHandle);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} else {
|
||||
// 降级到传统下载
|
||||
// Fallback to traditional download
|
||||
fallbackDownload(blob, fileName);
|
||||
if (enableToast) {
|
||||
toast.info("由于浏览器限制,文件将下载到默认下载目录");
|
||||
toast.info("Due to browser limitations, file will be downloaded to default download directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,22 +241,22 @@ export function useDragToSystemDesktop({
|
||||
if (enableToast) {
|
||||
toast.success(
|
||||
fileList.length === 1
|
||||
? `${fileName} 已保存到指定位置`
|
||||
: `${fileList.length} 个文件已打包保存`,
|
||||
? `${fileName} saved to specified location`
|
||||
: `${fileList.length} files packaged and saved`,
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// 重置状态
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("拖拽到桌面失败:", error);
|
||||
const errorMessage = error.message || "保存失败";
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const errorMessage = error.message || "Save failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -273,7 +266,7 @@ export function useDragToSystemDesktop({
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`保存失败: ${errorMessage}`);
|
||||
toast.error(`Save failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
@@ -283,7 +276,7 @@ export function useDragToSystemDesktop({
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
// 开始拖拽(记录拖拽数据)
|
||||
// Start dragging (record drag data)
|
||||
const startDragToSystem = useCallback(
|
||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
dragDataRef.current = { files, options };
|
||||
@@ -292,29 +285,27 @@ export function useDragToSystemDesktop({
|
||||
[],
|
||||
);
|
||||
|
||||
// 结束拖拽检测
|
||||
// End drag detection
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!dragDataRef.current) return;
|
||||
|
||||
const { files, options } = dragDataRef.current;
|
||||
|
||||
// 检查是否拖拽到窗口外
|
||||
// Check if dragged outside window
|
||||
if (isDraggedOutsideWindow(e)) {
|
||||
// 延迟执行,避免与其他拖拽事件冲突
|
||||
setTimeout(() => {
|
||||
handleDragToSystem(files, options);
|
||||
}, 100);
|
||||
// Execute immediately to preserve user gesture context for showSaveFilePicker
|
||||
handleDragToSystem(files, options);
|
||||
}
|
||||
|
||||
// 清理拖拽状态
|
||||
// Clean up drag state
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false }));
|
||||
},
|
||||
[handleDragToSystem],
|
||||
);
|
||||
|
||||
// 取消拖拽
|
||||
// Cancel dragging
|
||||
const cancelDragToSystem = useCallback(() => {
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||
@@ -326,6 +317,6 @@ export function useDragToSystemDesktop({
|
||||
startDragToSystem,
|
||||
handleDragEnd,
|
||||
cancelDragToSystem,
|
||||
handleDragToSystem, // 直接调用版本
|
||||
handleDragToSystem, // Direct call version
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,8 +123,10 @@ export function getCookie(name: string): string | undefined {
|
||||
} else {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
const token =
|
||||
const encodedToken =
|
||||
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
|
||||
// Decode the token since setCookie uses encodeURIComponent
|
||||
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -278,6 +280,27 @@ function createApiInstance(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DEK (Data Encryption Key) invalidation
|
||||
if (status === 423) {
|
||||
const errorData = error.response?.data;
|
||||
if (errorData?.error === "DATA_LOCKED" || errorData?.message?.includes("DATA_LOCKED")) {
|
||||
// DEK session has expired (likely due to server restart or timeout)
|
||||
// Force logout to require re-authentication and DEK unlock
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
document.cookie =
|
||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
// Trigger a page reload to redirect to login
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -376,7 +399,10 @@ if (isElectron()) {
|
||||
|
||||
function getApiUrl(path: string, defaultPort: number): string {
|
||||
if (isDev()) {
|
||||
return `http://${apiHost}:${defaultPort}${path}`;
|
||||
// Auto-detect HTTPS in development
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
const sslPort = protocol === "https" ? 8443 : defaultPort;
|
||||
return `${protocol}://${apiHost}:${sslPort}${path}`;
|
||||
} else if (isElectron()) {
|
||||
if (configuredServerUrl) {
|
||||
const baseUrl = configuredServerUrl.replace(/\/$/, "");
|
||||
@@ -737,6 +763,48 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH AUTOSTART MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/autostart/enable", { sshConfigId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "enable autostart");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAutoStart(sshConfigId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/autostart/disable", {
|
||||
data: { sshConfigId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disable autostart");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAutoStartStatus(): Promise<{
|
||||
autostart_configs: Array<{
|
||||
sshConfigId: number;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authType: string;
|
||||
}>;
|
||||
total_count: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/autostart/status");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch autostart status");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -955,6 +1023,17 @@ export async function getSSHStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepSSHAlive(sessionId: string): Promise<any> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSSHFiles(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
@@ -966,7 +1045,7 @@ export async function listSSHFiles(
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
return { files: [], path }; // 确保总是返回正确格式
|
||||
return { files: [], path }; // Ensure always return correct format
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,7 +1072,14 @@ export async function readSSHFile(
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Preserve fileNotFound information for 404 errors
|
||||
if (error.response?.status === 404) {
|
||||
const customError = new Error("File not found");
|
||||
(customError as any).response = error.response;
|
||||
(customError as any).isFileNotFound = error.response.data?.fileNotFound || true;
|
||||
throw customError;
|
||||
}
|
||||
handleApiError(error, "read SSH file");
|
||||
}
|
||||
}
|
||||
@@ -1155,7 +1241,7 @@ export async function copySSHItem(
|
||||
userId,
|
||||
},
|
||||
{
|
||||
timeout: 60000, // 60秒超时,因为文件复制可能需要更长时间
|
||||
timeout: 60000, // 60 second timeout as file copying may take longer
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
@@ -1201,6 +1287,8 @@ export async function moveSSHItem(
|
||||
newPath,
|
||||
hostId,
|
||||
userId,
|
||||
}, {
|
||||
timeout: 60000, // 60 second timeout for move operations
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -1446,6 +1534,15 @@ export async function getOIDCConfig(): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/setup-required");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "check setup status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserCount(): Promise<UserCount> {
|
||||
try {
|
||||
const response = await authApi.get("/users/count");
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
<TabsContent value="key">
|
||||
<div className="space-y-6">
|
||||
{/* Private Key Section */}
|
||||
<div className="space-y-4">
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* File Upload */}
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("hosts.uploadFile")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
field.onChange(file);
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
debouncedKeyDetection(
|
||||
fileContent,
|
||||
form.watch("keyPassword"),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to read uploaded file:", error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value instanceof File
|
||||
? field.value.name
|
||||
: t("credentials.upload")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Text Input */}
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("hosts.pasteKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={typeof field.value === "string" ? field.value : ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
debouncedKeyDetection(
|
||||
e.target.value,
|
||||
form.watch("keyPassword"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key type detection display */}
|
||||
{detectedKeyType && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("credentials.detectedKeyType")}:{" "}
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
detectedKeyType === "invalid" || detectedKeyType === "error"
|
||||
? "text-destructive"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
>
|
||||
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||
</span>
|
||||
{keyDetectionLoading && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({t("credentials.detecting")}...)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show existing private key for editing */}
|
||||
{editingCredential && fullCredentialDetails?.key && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
readOnly
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={fullCredentialDetails.key}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("credentials.currentKeyContent")}
|
||||
</div>
|
||||
{fullCredentialDetails?.detectedKeyType && (
|
||||
<div className="text-sm mt-2">
|
||||
<span className="text-muted-foreground">Key type: </span>
|
||||
<span className="font-medium text-green-600">
|
||||
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Public Key Section */}
|
||||
<div className="space-y-4">
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||
</FormLabel>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* File Upload */}
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="publicKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("hosts.uploadFile")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="public-key-upload"
|
||||
type="file"
|
||||
accept="*,.pub,.txt"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
field.onChange(fileContent);
|
||||
debouncedPublicKeyDetection(fileContent);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to read uploaded public key file:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value
|
||||
? t("credentials.publicKeyUploaded")
|
||||
: t("credentials.uploadPublicKey")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Text Input */}
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="publicKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("hosts.pasteKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t("placeholders.pastePublicKey")}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
debouncedPublicKeyDetection(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public key type detection */}
|
||||
{detectedPublicKeyType && form.watch("publicKey") && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("credentials.detectedKeyType")}:{" "}
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
detectedPublicKeyType === "invalid" ||
|
||||
detectedPublicKeyType === "error"
|
||||
? "text-destructive"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
>
|
||||
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||
</span>
|
||||
{publicKeyDetectionLoading && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({t("credentials.detecting")}...)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("credentials.publicKeyNote")}
|
||||
</div>
|
||||
|
||||
{/* Show existing public key for editing */}
|
||||
{editingCredential && fullCredentialDetails?.publicKey && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
readOnly
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={fullCredentialDetails.publicKey}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("credentials.currentPublicKeyContent")}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate Public Key Button */}
|
||||
{form.watch("key") && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGeneratePublicKey}
|
||||
disabled={generatePublicKeyLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{generatePublicKeyLoading ? (
|
||||
<>
|
||||
<span className="mr-2">{t("credentials.generating")}...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t("credentials.generatePublicKey")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
{t("credentials.generatePublicKeyNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>;
|
||||
@@ -1,8 +1,17 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// SSL certificate paths
|
||||
const sslCertPath = path.join(process.cwd(), "ssl/termix.crt");
|
||||
const sslKeyPath = path.join(process.cwd(), "ssl/termix.key");
|
||||
|
||||
// Check if SSL certificates exist and HTTPS is requested
|
||||
const hasSSL = fs.existsSync(sslCertPath) && fs.existsSync(sslKeyPath);
|
||||
const useHTTPS = process.env.VITE_HTTPS === "true" && hasSSL;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
@@ -12,4 +21,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
base: "./",
|
||||
server: {
|
||||
https: useHTTPS ? {
|
||||
cert: fs.readFileSync(sslCertPath),
|
||||
key: fs.readFileSync(sslKeyPath),
|
||||
} : false,
|
||||
port: 5173,
|
||||
host: "localhost",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user