v1.7.0 #318
@@ -1,49 +0,0 @@
|
|||||||
# 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=
|
|
||||||
|
|
||||||
# ===== 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)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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-dev:
|
|
||||||
image: ghcr.io/lukegus/termix:dev-1.7.0
|
|
||||||
container_name: termix-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "4300:4300"
|
|
||||||
- "8443:8443"
|
|
||||||
volumes:
|
|
||||||
- termix-dev-data:/app/data
|
|
||||||
environment:
|
|
||||||
PORT: "4300"
|
|
||||||
ENABLE_SSL: "true"
|
|
||||||
SSL_DOMAIN: "termix-dev.karmaa.site"
|
|
||||||
SSL_CERT_PATH: "/app/data/ssl/termix.crt"
|
|
||||||
SSL_KEY_PATH: "/app/data/ssl/termix.key"
|
|
||||||
# Resource limits for better large file handling
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 2G
|
|
||||||
cpus: '1.0'
|
|
||||||
reservations:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '0.5'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
termix-dev-data:
|
|
||||||
driver: local
|
|
||||||
@@ -9,9 +9,6 @@ export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
|||||||
|
|
||||||
echo "Configuring web UI to run on port: $PORT"
|
echo "Configuring web UI to run on port: $PORT"
|
||||||
|
|
||||||
# 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
|
if [ "$ENABLE_SSL" = "true" ]; then
|
||||||
echo "SSL enabled - using HTTPS configuration with redirect"
|
echo "SSL enabled - using HTTPS configuration with redirect"
|
||||||
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
||||||
@@ -27,18 +24,15 @@ mkdir -p /app/data
|
|||||||
chown -R node:node /app/data
|
chown -R node:node /app/data
|
||||||
chmod 755 /app/data
|
chmod 755 /app/data
|
||||||
|
|
||||||
# If SSL is enabled, generate certificates first
|
|
||||||
if [ "$ENABLE_SSL" = "true" ]; then
|
if [ "$ENABLE_SSL" = "true" ]; then
|
||||||
echo "Generating SSL certificates..."
|
echo "Generating SSL certificates..."
|
||||||
mkdir -p /app/data/ssl
|
mkdir -p /app/data/ssl
|
||||||
chown -R node:node /app/data/ssl
|
chown -R node:node /app/data/ssl
|
||||||
chmod 755 /app/data/ssl
|
chmod 755 /app/data/ssl
|
||||||
|
|
||||||
# Generate SSL certificates using OpenSSL directly (faster and more reliable)
|
|
||||||
DOMAIN=${SSL_DOMAIN:-localhost}
|
DOMAIN=${SSL_DOMAIN:-localhost}
|
||||||
echo "Generating certificate for domain: $DOMAIN"
|
echo "Generating certificate for domain: $DOMAIN"
|
||||||
|
|
||||||
# Create OpenSSL config
|
|
||||||
cat > /app/data/ssl/openssl.conf << EOF
|
cat > /app/data/ssl/openssl.conf << EOF
|
||||||
[req]
|
[req]
|
||||||
default_bits = 2048
|
default_bits = 2048
|
||||||
@@ -68,18 +62,14 @@ IP.1 = 127.0.0.1
|
|||||||
IP.2 = ::1
|
IP.2 = ::1
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Generate private key
|
|
||||||
openssl genrsa -out /app/data/ssl/termix.key 2048
|
openssl genrsa -out /app/data/ssl/termix.key 2048
|
||||||
|
|
||||||
# Generate certificate
|
|
||||||
openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req
|
openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req
|
||||||
|
|
||||||
# Set proper permissions
|
|
||||||
chmod 600 /app/data/ssl/termix.key
|
chmod 600 /app/data/ssl/termix.key
|
||||||
chmod 644 /app/data/ssl/termix.crt
|
chmod 644 /app/data/ssl/termix.crt
|
||||||
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
|
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
|
||||||
|
|
||||||
# Clean up config
|
|
||||||
rm -f /app/data/ssl/openssl.conf
|
rm -f /app/data/ssl/openssl.conf
|
||||||
|
|
||||||
echo "SSL certificates generated successfully for domain: $DOMAIN"
|
echo "SSL certificates generated successfully for domain: $DOMAIN"
|
||||||
|
|||||||
+3
-17
@@ -10,19 +10,16 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
client_header_timeout 300s;
|
client_header_timeout 300s;
|
||||||
|
|
||||||
# SSL Configuration
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
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_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_prefer_server_ciphers off;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
# HTTP Server - Redirect to HTTPS
|
|
||||||
server {
|
server {
|
||||||
listen ${PORT};
|
listen ${PORT};
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Redirect all HTTP traffic to HTTPS
|
|
||||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +28,9 @@ http {
|
|||||||
listen ${SSL_PORT} ssl;
|
listen ${SSL_PORT} ssl;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# SSL Certificate paths
|
|
||||||
ssl_certificate ${SSL_CERT_PATH};
|
ssl_certificate ${SSL_CERT_PATH};
|
||||||
ssl_certificate_key ${SSL_KEY_PATH};
|
ssl_certificate_key ${SSL_KEY_PATH};
|
||||||
|
|
||||||
# Security headers for HTTPS
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
@@ -118,35 +113,26 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket proxy for authenticated terminal connections
|
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
# Pass to WebSocket server with authentication support
|
|
||||||
proxy_pass http://127.0.0.1:30002/;
|
proxy_pass http://127.0.0.1:30002/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# WebSocket upgrade headers
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
# Pass client information for authentication logging
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Query parameters are passed by default with proxy_pass
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
# 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_buffering off;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
# Handle connection errors gracefully
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-15
@@ -10,19 +10,16 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
client_header_timeout 300s;
|
client_header_timeout 300s;
|
||||||
|
|
||||||
# SSL Configuration
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
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_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_prefer_server_ciphers off;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
# HTTP Server - Redirect to HTTPS when SSL enabled
|
|
||||||
server {
|
server {
|
||||||
listen ${PORT};
|
listen ${PORT};
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
@@ -104,35 +101,26 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket proxy for authenticated terminal connections
|
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
# Pass to WebSocket server with authentication support
|
|
||||||
proxy_pass http://127.0.0.1:30002/;
|
proxy_pass http://127.0.0.1:30002/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# WebSocket upgrade headers
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
# Pass client information for authentication logging
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Query parameters are passed by default with proxy_pass
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
# 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_buffering off;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
# Handle connection errors gracefully
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-201
@@ -14,7 +14,6 @@ if (!gotTheLock) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||||
console.log("Second instance detected, focusing existing window...");
|
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
@@ -51,12 +50,10 @@ function createWindow() {
|
|||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||||
console.log("Loading frontend from:", indexPath);
|
|
||||||
mainWindow.loadFile(indexPath);
|
mainWindow.loadFile(indexPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.once("ready-to-show", () => {
|
mainWindow.once("ready-to-show", () => {
|
||||||
console.log("Window ready to show");
|
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,17 +94,14 @@ ipcMain.handle("get-app-version", () => {
|
|||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
// GitHub API service for version checking
|
|
||||||
const GITHUB_API_BASE = "https://api.github.com";
|
const GITHUB_API_BASE = "https://api.github.com";
|
||||||
const REPO_OWNER = "LukeGus";
|
const REPO_OWNER = "LukeGus";
|
||||||
const REPO_NAME = "Termix";
|
const REPO_NAME = "Termix";
|
||||||
|
|
||||||
// Simple cache for GitHub API responses
|
|
||||||
const githubCache = new Map();
|
const githubCache = new Map();
|
||||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
async function fetchGitHubAPI(endpoint, cacheKey) {
|
async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||||
// Check cache first
|
|
||||||
const cached = githubCache.get(cacheKey);
|
const cached = githubCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||||
return {
|
return {
|
||||||
@@ -184,7 +178,6 @@ async function fetchGitHubAPI(endpoint, cacheKey) {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Cache the response
|
|
||||||
githubCache.set(cacheKey, {
|
githubCache.set(cacheKey, {
|
||||||
data,
|
data,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -200,15 +193,13 @@ async function fetchGitHubAPI(endpoint, cacheKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Electron app updates
|
|
||||||
ipcMain.handle("check-electron-update", async () => {
|
ipcMain.handle("check-electron-update", async () => {
|
||||||
try {
|
try {
|
||||||
const localVersion = app.getVersion();
|
const localVersion = app.getVersion();
|
||||||
console.log(`Checking for updates. Local version: ${localVersion}`);
|
|
||||||
|
|
||||||
const releaseData = await fetchGitHubAPI(
|
const releaseData = await fetchGitHubAPI(
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||||
"latest_release_electron"
|
"latest_release_electron",
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||||
@@ -216,7 +207,6 @@ ipcMain.handle("check-electron-update", async () => {
|
|||||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||||
|
|
||||||
if (!remoteVersion) {
|
if (!remoteVersion) {
|
||||||
console.warn("Remote version not found in GitHub response:", rawTag);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Remote version not found",
|
error: "Remote version not found",
|
||||||
@@ -242,10 +232,8 @@ ipcMain.handle("check-electron-update", async () => {
|
|||||||
cache_age: releaseData.cache_age,
|
cache_age: releaseData.cache_age,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Version check result: ${result.status}`);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Version check failed:", error);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -361,9 +349,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|||||||
data.includes("<head>") ||
|
data.includes("<head>") ||
|
||||||
data.includes("<body>")
|
data.includes("<body>")
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
"Health endpoint returned HTML instead of JSON - not a Termix server",
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
@@ -410,9 +395,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|||||||
data.includes("<head>") ||
|
data.includes("<head>") ||
|
||||||
data.includes("<body>")
|
data.includes("<body>")
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
"Version endpoint returned HTML instead of JSON - not a Termix server",
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
@@ -458,7 +440,6 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
console.log("Termix started successfully");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
@@ -475,187 +456,6 @@ app.on("activate", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ================== 拖拽功能实现 ==================
|
|
||||||
|
|
||||||
// 临时文件管理
|
|
||||||
const tempFiles = new Map(); // 存储临时文件路径映射
|
|
||||||
|
|
||||||
// 创建临时文件
|
|
||||||
ipcMain.handle("create-temp-file", async (event, fileData) => {
|
|
||||||
try {
|
|
||||||
const { fileName, content, encoding = "base64" } = fileData;
|
|
||||||
|
|
||||||
// 创建临时目录
|
|
||||||
const tempDir = path.join(os.tmpdir(), "termix-drag-files");
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成临时文件路径
|
|
||||||
const tempId = Date.now() + "-" + Math.random().toString(36).substr(2, 9);
|
|
||||||
const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`);
|
|
||||||
|
|
||||||
// 写入文件内容
|
|
||||||
if (encoding === "base64") {
|
|
||||||
const buffer = Buffer.from(content, "base64");
|
|
||||||
fs.writeFileSync(tempFilePath, buffer);
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(tempFilePath, content, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录临时文件
|
|
||||||
tempFiles.set(tempId, {
|
|
||||||
path: tempFilePath,
|
|
||||||
fileName: fileName,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Created temp file: ${tempFilePath}`);
|
|
||||||
return { success: true, tempId, path: tempFilePath };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating temp file:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 开始拖拽到桌面
|
|
||||||
ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => {
|
|
||||||
try {
|
|
||||||
const tempFile = tempFiles.get(tempId);
|
|
||||||
if (!tempFile) {
|
|
||||||
throw new Error("Temporary file not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用Electron的startDrag API
|
|
||||||
const iconPath = path.join(__dirname, "..", "public", "icon.png");
|
|
||||||
const iconExists = fs.existsSync(iconPath);
|
|
||||||
|
|
||||||
mainWindow.webContents.startDrag({
|
|
||||||
file: tempFile.path,
|
|
||||||
icon: iconExists ? iconPath : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Started drag for: ${tempFile.path}`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error starting drag:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 清理临时文件
|
|
||||||
ipcMain.handle("cleanup-temp-file", async (event, tempId) => {
|
|
||||||
try {
|
|
||||||
const tempFile = tempFiles.get(tempId);
|
|
||||||
if (tempFile && fs.existsSync(tempFile.path)) {
|
|
||||||
fs.unlinkSync(tempFile.path);
|
|
||||||
tempFiles.delete(tempId);
|
|
||||||
console.log(`Cleaned up temp file: ${tempFile.path}`);
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cleaning up temp file:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 批量清理过期临时文件(5分钟过期)
|
|
||||||
const cleanupExpiredTempFiles = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const maxAge = 5 * 60 * 1000; // 5分钟
|
|
||||||
|
|
||||||
for (const [tempId, tempFile] of tempFiles.entries()) {
|
|
||||||
if (now - tempFile.createdAt > maxAge) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempFile.path)) {
|
|
||||||
fs.unlinkSync(tempFile.path);
|
|
||||||
}
|
|
||||||
tempFiles.delete(tempId);
|
|
||||||
console.log(`Auto-cleaned expired temp file: ${tempFile.path}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error auto-cleaning temp file:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 每分钟清理一次过期临时文件
|
|
||||||
setInterval(cleanupExpiredTempFiles, 60 * 1000);
|
|
||||||
|
|
||||||
// 创建临时文件夹拖拽支持
|
|
||||||
ipcMain.handle("create-temp-folder", async (event, folderData) => {
|
|
||||||
try {
|
|
||||||
const { folderName, files } = folderData;
|
|
||||||
|
|
||||||
// 创建临时目录
|
|
||||||
const tempDir = path.join(os.tmpdir(), "termix-drag-folders");
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempId = Date.now() + "-" + Math.random().toString(36).substr(2, 9);
|
|
||||||
const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`);
|
|
||||||
|
|
||||||
// 递归创建文件夹结构
|
|
||||||
const createFolderStructure = (basePath, fileList) => {
|
|
||||||
for (const file of fileList) {
|
|
||||||
const fullPath = path.join(basePath, file.relativePath);
|
|
||||||
const dirPath = path.dirname(fullPath);
|
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
if (file.encoding === "base64") {
|
|
||||||
const buffer = Buffer.from(file.content, "base64");
|
|
||||||
fs.writeFileSync(fullPath, buffer);
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(fullPath, file.content, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.mkdirSync(tempFolderPath, { recursive: true });
|
|
||||||
createFolderStructure(tempFolderPath, files);
|
|
||||||
|
|
||||||
// 记录临时文件夹
|
|
||||||
tempFiles.set(tempId, {
|
|
||||||
path: tempFolderPath,
|
|
||||||
fileName: folderName,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
isFolder: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Created temp folder: ${tempFolderPath}`);
|
|
||||||
return { success: true, tempId, path: tempFolderPath };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating temp folder:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
|
||||||
console.log("App is quitting...");
|
|
||||||
|
|
||||||
// 清理所有临时文件
|
|
||||||
for (const [tempId, tempFile] of tempFiles.entries()) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempFile.path)) {
|
|
||||||
if (tempFile.isFolder) {
|
|
||||||
fs.rmSync(tempFile.path, { recursive: true, force: true });
|
|
||||||
} else {
|
|
||||||
fs.unlinkSync(tempFile.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cleaning up temp file on quit:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tempFiles.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
console.log("App will quit...");
|
console.log("App will quit...");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,25 +23,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
isDev: process.env.NODE_ENV === "development",
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||||
|
|
||||||
// ================== 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),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.IS_ELECTRON = true;
|
window.IS_ELECTRON = true;
|
||||||
|
|
||||||
console.log("electronAPI exposed to window");
|
|
||||||
|
|||||||
Vendored
+58101
-2
File diff suppressed because one or more lines are too long
+7
-77
@@ -1,17 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Termix SSL Quick Setup Script
|
|
||||||
# Enables HTTPS/WSS with one command
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m'
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
@@ -37,28 +33,12 @@ log_header() {
|
|||||||
echo -e "${CYAN}$1${NC}"
|
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() {
|
generate_keys() {
|
||||||
log_info "🔑 Generating security keys..."
|
log_info "Generating security keys..."
|
||||||
|
|
||||||
# Generate JWT secret
|
|
||||||
JWT_SECRET=$(openssl rand -hex 32)
|
JWT_SECRET=$(openssl rand -hex 32)
|
||||||
log_success "Generated JWT secret"
|
log_success "Generated JWT secret"
|
||||||
|
|
||||||
# Generate database key
|
|
||||||
DATABASE_KEY=$(openssl rand -hex 32)
|
DATABASE_KEY=$(openssl rand -hex 32)
|
||||||
log_success "Generated database encryption key"
|
log_success "Generated database encryption key"
|
||||||
|
|
||||||
@@ -69,14 +49,13 @@ generate_keys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup_env_file() {
|
setup_env_file() {
|
||||||
log_info "📝 Setting up environment configuration..."
|
log_info "Setting up environment configuration..."
|
||||||
|
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
log_warn "⚠️ .env file already exists, creating backup..."
|
log_warn ".env file already exists, creating backup..."
|
||||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
|
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create or update .env file
|
|
||||||
cat > "$ENV_FILE" << EOF
|
cat > "$ENV_FILE" << EOF
|
||||||
# Termix SSL Configuration - Auto-generated $(date)
|
# Termix SSL Configuration - Auto-generated $(date)
|
||||||
|
|
||||||
@@ -94,79 +73,30 @@ ALLOWED_ORIGINS=*
|
|||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Add security keys
|
|
||||||
generate_keys
|
generate_keys
|
||||||
|
|
||||||
log_success "Environment configuration created at $ENV_FILE"
|
log_success "Environment configuration created at $ENV_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_ssl_certificates() {
|
setup_ssl_certificates() {
|
||||||
log_info "🔐 Setting up SSL certificates..."
|
log_info "Setting up SSL certificates..."
|
||||||
|
|
||||||
# Run SSL setup script
|
|
||||||
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
|
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
|
||||||
bash "$SCRIPT_DIR/setup-ssl.sh"
|
bash "$SCRIPT_DIR/setup-ssl.sh"
|
||||||
else
|
else
|
||||||
log_error "❌ SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
|
log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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() {
|
main() {
|
||||||
print_banner
|
|
||||||
|
|
||||||
# Check prerequisites
|
|
||||||
if ! command -v openssl &> /dev/null; then
|
if ! command -v openssl &> /dev/null; then
|
||||||
log_error "❌ OpenSSL is not installed. Please install OpenSSL first."
|
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup environment
|
|
||||||
setup_env_file
|
setup_env_file
|
||||||
|
|
||||||
# Setup SSL certificates
|
|
||||||
setup_ssl_certificates
|
setup_ssl_certificates
|
||||||
|
|
||||||
# Show completion message
|
|
||||||
show_next_steps
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run main function
|
# Run main function
|
||||||
|
|||||||
+9
-83
@@ -1,26 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Termix SSL Certificate Auto-Setup Script
|
|
||||||
# Linus principle: Simple, automatic, works everywhere
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SSL_DIR="$(dirname "$0")/../ssl"
|
SSL_DIR="$(dirname "$0")/../ssl"
|
||||||
CERT_FILE="$SSL_DIR/termix.crt"
|
CERT_FILE="$SSL_DIR/termix.crt"
|
||||||
KEY_FILE="$SSL_DIR/termix.key"
|
KEY_FILE="$SSL_DIR/termix.key"
|
||||||
DAYS_VALID=365
|
DAYS_VALID=365
|
||||||
|
|
||||||
# Default domain - can be overridden by environment variable
|
|
||||||
DOMAIN=${SSL_DOMAIN:-"localhost"}
|
DOMAIN=${SSL_DOMAIN:-"localhost"}
|
||||||
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
|
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'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m'
|
||||||
|
|
||||||
log_info() {
|
log_info() {
|
||||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||||
@@ -38,34 +32,26 @@ log_error() {
|
|||||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if certificate exists and is still valid
|
|
||||||
check_existing_cert() {
|
check_existing_cert() {
|
||||||
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
|
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
|
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
|
||||||
log_success "✅ Valid SSL certificate already exists"
|
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)
|
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
log_info "Expires: $expiry"
|
log_info "Expires: $expiry"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_warn "⚠️ Existing certificate is expired or expiring soon"
|
log_warn "Existing certificate is expired or expiring soon"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate self-signed certificate
|
|
||||||
generate_certificate() {
|
generate_certificate() {
|
||||||
log_info "🔐 Generating new SSL certificate for domain: $DOMAIN"
|
log_info "Generating new SSL certificate for domain: $DOMAIN"
|
||||||
|
|
||||||
# Create SSL directory if it doesn't exist
|
|
||||||
mkdir -p "$SSL_DIR"
|
mkdir -p "$SSL_DIR"
|
||||||
|
|
||||||
# Create OpenSSL config for SAN (Subject Alternative Names)
|
|
||||||
local config_file="$SSL_DIR/openssl.conf"
|
local config_file="$SSL_DIR/openssl.conf"
|
||||||
cat > "$config_file" << EOF
|
cat > "$config_file" << EOF
|
||||||
[req]
|
[req]
|
||||||
@@ -95,12 +81,11 @@ DNS.3 = *.localhost
|
|||||||
IP.1 = 127.0.0.1
|
IP.1 = 127.0.0.1
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Add custom alt names if provided
|
|
||||||
if [[ -n "$SSL_ALT_NAMES" ]]; then
|
if [[ -n "$SSL_ALT_NAMES" ]]; then
|
||||||
local counter=2
|
local counter=2
|
||||||
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
|
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
|
||||||
for name in "${NAMES[@]}"; do
|
for name in "${NAMES[@]}"; do
|
||||||
name=$(echo "$name" | xargs) # trim whitespace
|
name=$(echo "$name" | xargs)
|
||||||
if [[ "$name" == DNS:* ]]; then
|
if [[ "$name" == DNS:* ]]; then
|
||||||
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
|
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
|
||||||
elif [[ "$name" == IP:* ]]; then
|
elif [[ "$name" == IP:* ]]; then
|
||||||
@@ -109,87 +94,28 @@ EOF
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate private key
|
log_info "Generating private key..."
|
||||||
log_info "📝 Generating private key..."
|
|
||||||
openssl genrsa -out "$KEY_FILE" 2048
|
openssl genrsa -out "$KEY_FILE" 2048
|
||||||
|
|
||||||
# Generate certificate
|
log_info "Generating certificate..."
|
||||||
log_info "📄 Generating certificate..."
|
|
||||||
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
|
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 600 "$KEY_FILE"
|
||||||
chmod 644 "$CERT_FILE"
|
chmod 644 "$CERT_FILE"
|
||||||
|
|
||||||
# Clean up temp config
|
|
||||||
rm -f "$config_file"
|
rm -f "$config_file"
|
||||||
|
|
||||||
log_success "✅ SSL certificate generated successfully"
|
log_success "SSL certificate generated successfully"
|
||||||
log_info "Certificate: $CERT_FILE"
|
|
||||||
log_info "Private Key: $KEY_FILE"
|
|
||||||
log_info "Valid for: $DAYS_VALID days"
|
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() {
|
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
|
if ! command -v openssl &> /dev/null; then
|
||||||
log_error "❌ OpenSSL is not installed. Please install OpenSSL first."
|
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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 "$@"
|
main "$@"
|
||||||
+288
-300
File diff suppressed because it is too large
Load Diff
@@ -19,125 +19,51 @@ if (!fs.existsSync(dbDir)) {
|
|||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database file encryption configuration
|
|
||||||
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||||
const dbPath = path.join(dataDir, "db.sqlite");
|
const dbPath = path.join(dataDir, "db.sqlite");
|
||||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||||
|
|
||||||
// Initialize database with file encryption support
|
let actualDbPath = ":memory:";
|
||||||
let actualDbPath = ":memory:"; // Always use memory database
|
|
||||||
let memoryDatabase: Database.Database;
|
let memoryDatabase: Database.Database;
|
||||||
let isNewDatabase = false;
|
let isNewDatabase = false;
|
||||||
let sqlite: Database.Database; // Module-level sqlite instance
|
let sqlite: Database.Database;
|
||||||
|
|
||||||
// Async initialization function to handle SystemCrypto and DatabaseFileEncryption
|
|
||||||
async function initializeDatabaseAsync(): Promise<void> {
|
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
|
|
||||||
// Verify key is available (should already be initialized by starter.ts)
|
|
||||||
const dbKey = await systemCrypto.getDatabaseKey();
|
const dbKey = await systemCrypto.getDatabaseKey();
|
||||||
databaseLogger.info("SystemCrypto database key verified", {
|
|
||||||
operation: "db_init_systemcrypto_complete",
|
|
||||||
keyLength: dbKey.length,
|
|
||||||
keyAvailable: !!dbKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
try {
|
try {
|
||||||
// Check if encrypted database exists
|
|
||||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||||
databaseLogger.info(
|
|
||||||
"Found encrypted database file, loading into memory...",
|
|
||||||
{
|
|
||||||
operation: "db_memory_load",
|
|
||||||
encryptedPath: encryptedDbPath,
|
|
||||||
fileSize: fs.statSync(encryptedDbPath).size,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decrypt database content to memory buffer (now async)
|
|
||||||
databaseLogger.info("Starting database decryption...", {
|
|
||||||
operation: "db_decrypt_start",
|
|
||||||
encryptedPath: encryptedDbPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decryptedBuffer =
|
const decryptedBuffer =
|
||||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||||
|
|
||||||
databaseLogger.info("Database decryption successful", {
|
|
||||||
operation: "db_decrypt_success",
|
|
||||||
decryptedSize: decryptedBuffer.length,
|
|
||||||
isSqlite: decryptedBuffer.slice(0, 16).toString().startsWith('SQLite format 3'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create in-memory database from decrypted buffer
|
|
||||||
memoryDatabase = new Database(decryptedBuffer);
|
memoryDatabase = new Database(decryptedBuffer);
|
||||||
|
|
||||||
databaseLogger.info("In-memory database created from decrypted buffer", {
|
|
||||||
operation: "db_memory_create_success",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// No encrypted database exists - check if we need to migrate
|
|
||||||
const migration = new DatabaseMigration(dataDir);
|
const migration = new DatabaseMigration(dataDir);
|
||||||
const migrationStatus = migration.checkMigrationStatus();
|
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) {
|
if (migrationStatus.needsMigration) {
|
||||||
// Perform automatic migration
|
|
||||||
databaseLogger.info("Starting automatic database migration", {
|
|
||||||
operation: "auto_migration_start",
|
|
||||||
unencryptedDbSize: migrationStatus.unencryptedDbSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const migrationResult = await migration.migrateDatabase();
|
const migrationResult = await migration.migrateDatabase();
|
||||||
|
|
||||||
if (migrationResult.success) {
|
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();
|
migration.cleanupOldBackups();
|
||||||
|
|
||||||
// Load the newly created encrypted database
|
if (
|
||||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||||
databaseLogger.info("Loading migrated encrypted database into memory", {
|
) {
|
||||||
operation: "load_migrated_db",
|
const decryptedBuffer =
|
||||||
encryptedPath: encryptedDbPath,
|
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
||||||
});
|
encryptedDbPath,
|
||||||
|
);
|
||||||
const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
|
||||||
memoryDatabase = new Database(decryptedBuffer);
|
memoryDatabase = new Database(decryptedBuffer);
|
||||||
isNewDatabase = false; // We have migrated data
|
isNewDatabase = false;
|
||||||
|
|
||||||
databaseLogger.success("Migrated encrypted database loaded successfully", {
|
|
||||||
operation: "load_migrated_db_success",
|
|
||||||
decryptedSize: decryptedBuffer.length,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Migration completed but encrypted database file not found");
|
throw new Error(
|
||||||
|
"Migration completed but encrypted database file not found",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Migration failed - this is critical
|
|
||||||
databaseLogger.error("Automatic database migration failed", null, {
|
databaseLogger.error("Automatic database migration failed", null, {
|
||||||
operation: "auto_migration_failed",
|
operation: "auto_migration_failed",
|
||||||
error: migrationResult.error,
|
error: migrationResult.error,
|
||||||
@@ -146,24 +72,13 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
duration: migrationResult.duration,
|
duration: migrationResult.duration,
|
||||||
backupPath: migrationResult.backupPath,
|
backupPath: migrationResult.backupPath,
|
||||||
});
|
});
|
||||||
|
throw new Error(
|
||||||
// CRITICAL: Migration failure with existing data
|
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
||||||
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 {
|
} else {
|
||||||
// No migration needed - create fresh database
|
|
||||||
memoryDatabase = new Database(":memory:");
|
memoryDatabase = new Database(":memory:");
|
||||||
isNewDatabase = true;
|
isNewDatabase = true;
|
||||||
|
|
||||||
databaseLogger.info("Creating fresh in-memory database", {
|
|
||||||
operation: "fresh_db_create",
|
|
||||||
reason: migrationStatus.reason,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -171,20 +86,15 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
operation: "db_memory_init_failed",
|
operation: "db_memory_init_failed",
|
||||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||||
errorStack: error instanceof Error ? error.stack : undefined,
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
encryptedDbExists:
|
||||||
|
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// CRITICAL: Never silently ignore database decryption failures!
|
throw new Error(
|
||||||
// This causes complete data loss for users
|
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||||
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 {
|
} else {
|
||||||
memoryDatabase = new Database(":memory:");
|
memoryDatabase = new Database(":memory:");
|
||||||
@@ -192,9 +102,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main async initialization function that combines database setup with schema creation
|
|
||||||
async function initializeCompleteDatabase(): Promise<void> {
|
async function initializeCompleteDatabase(): Promise<void> {
|
||||||
// First initialize the database and SystemCrypto
|
|
||||||
await initializeDatabaseAsync();
|
await initializeDatabaseAsync();
|
||||||
|
|
||||||
databaseLogger.info(`Initializing SQLite database`, {
|
databaseLogger.info(`Initializing SQLite database`, {
|
||||||
@@ -207,17 +115,10 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
isNewDatabase,
|
isNewDatabase,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create module-level sqlite instance after database is initialized
|
|
||||||
sqlite = memoryDatabase;
|
sqlite = memoryDatabase;
|
||||||
|
|
||||||
// Initialize drizzle ORM with the configured database
|
|
||||||
db = drizzle(sqlite, { schema });
|
db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
databaseLogger.info("Database ORM initialized", {
|
|
||||||
operation: "drizzle_init",
|
|
||||||
tablesConfigured: Object.keys(schema).length
|
|
||||||
});
|
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -340,19 +241,13 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Run schema migrations
|
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
// Initialize default settings
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite
|
const row = sqlite
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||||
.get();
|
.get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
databaseLogger.info("Initializing default settings", {
|
|
||||||
operation: "db_init",
|
|
||||||
setting: "allow_registration",
|
|
||||||
});
|
|
||||||
sqlite
|
sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||||
@@ -383,11 +278,6 @@ const addColumnIfNotExists = (
|
|||||||
try {
|
try {
|
||||||
sqlite.exec(`ALTER TABLE ${table}
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN ${column} ${definition};`);
|
ADD COLUMN ${column} ${definition};`);
|
||||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
|
||||||
operation: "schema_migration",
|
|
||||||
table,
|
|
||||||
column,
|
|
||||||
});
|
|
||||||
} catch (alterError) {
|
} catch (alterError) {
|
||||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
@@ -400,10 +290,6 @@ const addColumnIfNotExists = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
databaseLogger.info("Checking for schema updates...", {
|
|
||||||
operation: "schema_migration",
|
|
||||||
});
|
|
||||||
|
|
||||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||||
@@ -469,13 +355,10 @@ const migrateSchema = () => {
|
|||||||
"INTEGER REFERENCES ssh_credentials(id)",
|
"INTEGER REFERENCES ssh_credentials(id)",
|
||||||
);
|
);
|
||||||
|
|
||||||
// AutoStart plaintext columns
|
|
||||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||||
|
|
||||||
|
|
||||||
// SSH credentials table migrations for encryption support
|
|
||||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||||
@@ -489,28 +372,22 @@ const migrateSchema = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to save in-memory database to file (encrypted or unencrypted fallback)
|
|
||||||
async function saveMemoryDatabaseToFile() {
|
async function saveMemoryDatabaseToFile() {
|
||||||
if (!memoryDatabase) return;
|
if (!memoryDatabase) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Export in-memory database to buffer
|
|
||||||
const buffer = memoryDatabase.serialize();
|
const buffer = memoryDatabase.serialize();
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
databaseLogger.info("Created data directory", {
|
|
||||||
operation: "data_dir_create",
|
|
||||||
path: dataDir,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
// Save as encrypted file
|
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
buffer,
|
||||||
|
encryptedDbPath,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: save as unencrypted SQLite file to prevent data loss
|
|
||||||
fs.writeFileSync(dbPath, buffer);
|
fs.writeFileSync(dbPath, buffer);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -521,55 +398,30 @@ async function saveMemoryDatabaseToFile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to handle post-initialization file encryption and periodic saves
|
|
||||||
async function handlePostInitFileEncryption() {
|
async function handlePostInitFileEncryption() {
|
||||||
if (!enableFileEncryption) return;
|
if (!enableFileEncryption) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 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(
|
|
||||||
"Unencrypted database file still exists after initialization",
|
|
||||||
{
|
|
||||||
operation: "db_security_check",
|
|
||||||
path: dbPath,
|
|
||||||
note: "This may be normal if migration was skipped for safety reasons",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
if (memoryDatabase) {
|
if (memoryDatabase) {
|
||||||
// Save immediately after initialization
|
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
|
|
||||||
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);
|
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||||
|
|
||||||
// Initialize database save trigger for real-time saves
|
|
||||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform migration cleanup on startup (remove old backup files)
|
|
||||||
try {
|
try {
|
||||||
const migration = new DatabaseMigration(dataDir);
|
const migration = new DatabaseMigration(dataDir);
|
||||||
migration.cleanupOldBackups();
|
migration.cleanupOldBackups();
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
databaseLogger.warn("Failed to cleanup old migration files", {
|
databaseLogger.warn("Failed to cleanup old migration files", {
|
||||||
operation: "migration_cleanup_startup_failed",
|
operation: "migration_cleanup_startup_failed",
|
||||||
error: cleanupError instanceof Error ? cleanupError.message : "Unknown error",
|
error:
|
||||||
|
cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to handle database file encryption setup",
|
"Failed to handle database file encryption setup",
|
||||||
@@ -578,31 +430,17 @@ async function handlePostInitFileEncryption() {
|
|||||||
operation: "db_encrypt_setup_failed",
|
operation: "db_encrypt_setup_failed",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Don't fail the entire initialization for this
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database initialization function - called explicitly, not at module import time
|
|
||||||
async function initializeDatabase(): Promise<void> {
|
async function initializeDatabase(): Promise<void> {
|
||||||
await initializeCompleteDatabase();
|
await initializeCompleteDatabase();
|
||||||
await handlePostInitFileEncryption();
|
await handlePostInitFileEncryption();
|
||||||
|
|
||||||
databaseLogger.success("Database connection established", {
|
|
||||||
operation: "db_init",
|
|
||||||
path: actualDbPath,
|
|
||||||
hasEncryptedBackup:
|
|
||||||
enableFileEncryption &&
|
|
||||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the initialization function instead of auto-starting
|
|
||||||
export { initializeDatabase };
|
export { initializeDatabase };
|
||||||
|
|
||||||
// Cleanup function for database and temporary files
|
|
||||||
async function cleanupDatabase() {
|
async function cleanupDatabase() {
|
||||||
// Save in-memory database before closing
|
|
||||||
if (memoryDatabase) {
|
if (memoryDatabase) {
|
||||||
try {
|
try {
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
@@ -617,7 +455,6 @@ async function cleanupDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close database connection
|
|
||||||
try {
|
try {
|
||||||
if (sqlite) {
|
if (sqlite) {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
@@ -629,7 +466,6 @@ async function cleanupDatabase() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up temp directory
|
|
||||||
try {
|
try {
|
||||||
const tempDir = path.join(dataDir, ".temp");
|
const tempDir = path.join(dataDir, ".temp");
|
||||||
if (fs.existsSync(tempDir)) {
|
if (fs.existsSync(tempDir)) {
|
||||||
@@ -637,25 +473,17 @@ async function cleanupDatabase() {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(path.join(tempDir, file));
|
fs.unlinkSync(path.join(tempDir, file));
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore individual file cleanup errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmdirSync(tempDir);
|
fs.rmdirSync(tempDir);
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore directory removal errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore temp directory cleanup errors
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register cleanup handlers
|
|
||||||
process.on("exit", () => {
|
process.on("exit", () => {
|
||||||
// Synchronous cleanup only for exit event
|
|
||||||
if (sqlite) {
|
if (sqlite) {
|
||||||
try {
|
try {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
@@ -679,26 +507,26 @@ process.on("SIGTERM", async () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Database connection - will be initialized after database setup
|
|
||||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
// Export database connection getter function to avoid undefined access
|
|
||||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
throw new Error("Database not initialized. Ensure initializeDatabase() is called before accessing db.");
|
throw new Error(
|
||||||
|
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export raw SQLite instance for migrations
|
|
||||||
export function getSqlite(): Database.Database {
|
export function getSqlite(): Database.Database {
|
||||||
if (!sqlite) {
|
if (!sqlite) {
|
||||||
throw new Error("SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.");
|
throw new Error(
|
||||||
|
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return sqlite;
|
return sqlite;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy export for compatibility - will throw if accessed before initialization
|
|
||||||
export { db };
|
export { db };
|
||||||
export { DatabaseFileEncryption };
|
export { DatabaseFileEncryption };
|
||||||
export const databasePaths = {
|
export const databasePaths = {
|
||||||
@@ -708,30 +536,6 @@ export const databasePaths = {
|
|||||||
inMemory: true,
|
inMemory: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memory database buffer function
|
export { saveMemoryDatabaseToFile };
|
||||||
function getMemoryDatabaseBuffer(): Buffer {
|
|
||||||
if (!memoryDatabase) {
|
|
||||||
throw new Error("Memory database not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Export in-memory database to buffer
|
|
||||||
const buffer = memoryDatabase.serialize();
|
|
||||||
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 };
|
|
||||||
|
|
||||||
// Export database save trigger for real-time saves
|
|
||||||
export { DatabaseSaveTrigger };
|
export { DatabaseSaveTrigger };
|
||||||
|
|||||||
@@ -1,600 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -49,7 +49,6 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
keyPassword: text("key_password"),
|
keyPassword: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
|
|
||||||
// AutoStart plaintext fields (populated only when autoStart is enabled)
|
|
||||||
autostartPassword: text("autostart_password"),
|
autostartPassword: text("autostart_password"),
|
||||||
autostartKey: text("autostart_key", { length: 8192 }),
|
autostartKey: text("autostart_key", { length: 8192 }),
|
||||||
autostartKeyPassword: text("autostart_key_password"),
|
autostartKeyPassword: text("autostart_key_password"),
|
||||||
@@ -142,7 +141,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 16384 }), // backward compatibility
|
key: text("key", { length: 16384 }),
|
||||||
privateKey: text("private_key", { length: 16384 }),
|
privateKey: text("private_key", { length: 16384 }),
|
||||||
publicKey: text("public_key", { length: 4096 }),
|
publicKey: text("public_key", { length: 4096 }),
|
||||||
keyPassword: text("key_password"),
|
keyPassword: text("key_password"),
|
||||||
@@ -173,4 +172,3 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Initialize auth middleware
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
@@ -144,8 +143,6 @@ router.get("/", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deprecated endpoint - use GET /alerts instead
|
|
||||||
|
|
||||||
// Route: Dismiss an alert for the authenticated user
|
// Route: Dismiss an alert for the authenticated user
|
||||||
// POST /alerts/dismiss
|
// POST /alerts/dismiss
|
||||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import crypto from "crypto";
|
|||||||
import ssh2Pkg from "ssh2";
|
import ssh2Pkg from "ssh2";
|
||||||
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
||||||
|
|
||||||
// Direct SSH key generation with ssh2 - the right way
|
|
||||||
function generateSSHKeyPair(
|
function generateSSHKeyPair(
|
||||||
keyType: string,
|
keyType: string,
|
||||||
keySize?: number,
|
keySize?: number,
|
||||||
@@ -29,7 +28,6 @@ function generateSSHKeyPair(
|
|||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
try {
|
try {
|
||||||
// Convert our keyType to ssh2 format
|
|
||||||
let ssh2Type = keyType;
|
let ssh2Type = keyType;
|
||||||
const options: any = {};
|
const options: any = {};
|
||||||
|
|
||||||
@@ -40,16 +38,14 @@ function generateSSHKeyPair(
|
|||||||
ssh2Type = "ed25519";
|
ssh2Type = "ed25519";
|
||||||
} else if (keyType === "ecdsa-sha2-nistp256") {
|
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||||
ssh2Type = "ecdsa";
|
ssh2Type = "ecdsa";
|
||||||
options.bits = 256; // ECDSA P-256 uses 256 bits
|
options.bits = 256;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add passphrase protection if provided
|
|
||||||
if (passphrase && passphrase.trim()) {
|
if (passphrase && passphrase.trim()) {
|
||||||
options.passphrase = passphrase;
|
options.passphrase = passphrase;
|
||||||
options.cipher = "aes128-cbc"; // Default cipher for encrypted private keys
|
options.cipher = "aes128-cbc";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ssh2's native key generation
|
|
||||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,24 +64,21 @@ function generateSSHKeyPair(
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
interface JWTPayload {
|
|
||||||
userId: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNonEmptyString(val: any): val is string {
|
function isNonEmptyString(val: any): val is string {
|
||||||
return typeof val === "string" && val.trim().length > 0;
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AuthManager middleware for authentication
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
// Create a new credential
|
// Create a new credential
|
||||||
// POST /credentials
|
// POST /credentials
|
||||||
router.post("/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.post(
|
||||||
|
"/",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@@ -149,7 +142,8 @@ router.post("/", authenticateJWT, requireDataAccess, async (req: Request, res: R
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "SSH key is required for key authentication" });
|
.json({ error: "SSH key is required for key authentication" });
|
||||||
}
|
}
|
||||||
const plainPassword = authType === "password" && password ? password : null;
|
const plainPassword =
|
||||||
|
authType === "password" && password ? password : null;
|
||||||
const plainKey = authType === "key" && key ? key : null;
|
const plainKey = authType === "key" && key ? key : null;
|
||||||
const plainKeyPassword =
|
const plainKeyPassword =
|
||||||
authType === "key" && keyPassword ? keyPassword : null;
|
authType === "key" && keyPassword ? keyPassword : null;
|
||||||
@@ -179,7 +173,7 @@ router.post("/", authenticateJWT, requireDataAccess, async (req: Request, res: R
|
|||||||
authType,
|
authType,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: plainPassword,
|
password: plainPassword,
|
||||||
key: plainKey, // backward compatibility
|
key: plainKey,
|
||||||
privateKey: keyInfo?.privateKey || plainKey,
|
privateKey: keyInfo?.privateKey || plainKey,
|
||||||
publicKey: keyInfo?.publicKey || null,
|
publicKey: keyInfo?.publicKey || null,
|
||||||
keyPassword: plainKeyPassword,
|
keyPassword: plainKeyPassword,
|
||||||
@@ -218,14 +212,20 @@ router.post("/", authenticateJWT, requireDataAccess, async (req: Request, res: R
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : "Failed to create credential",
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to create credential",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get all credentials for the authenticated user
|
// Get all credentials for the authenticated user
|
||||||
// GET /credentials
|
// GET /credentials
|
||||||
router.get("/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.get(
|
||||||
|
"/",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
@@ -249,11 +249,16 @@ router.get("/", authenticateJWT, requireDataAccess, async (req: Request, res: Re
|
|||||||
authLogger.error("Failed to fetch credentials", err);
|
authLogger.error("Failed to fetch credentials", err);
|
||||||
res.status(500).json({ error: "Failed to fetch credentials" });
|
res.status(500).json({ error: "Failed to fetch credentials" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get all unique credential folders for the authenticated user
|
// Get all unique credential folders for the authenticated user
|
||||||
// GET /credentials/folders
|
// GET /credentials/folders
|
||||||
router.get("/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.get(
|
||||||
|
"/folders",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
@@ -282,11 +287,16 @@ router.get("/folders", authenticateJWT, requireDataAccess, async (req: Request,
|
|||||||
authLogger.error("Failed to fetch credential folders", err);
|
authLogger.error("Failed to fetch credential folders", err);
|
||||||
res.status(500).json({ error: "Failed to fetch credential folders" });
|
res.status(500).json({ error: "Failed to fetch credential folders" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get a specific credential by ID (with plain text secrets)
|
// Get a specific credential by ID (with plain text secrets)
|
||||||
// GET /credentials/:id
|
// GET /credentials/:id
|
||||||
router.get("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.get(
|
||||||
|
"/:id",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -321,7 +331,7 @@ router.get("/:id", authenticateJWT, requireDataAccess, async (req: Request, res:
|
|||||||
(output as any).password = credential.password;
|
(output as any).password = credential.password;
|
||||||
}
|
}
|
||||||
if (credential.key) {
|
if (credential.key) {
|
||||||
(output as any).key = credential.key; // backward compatibility
|
(output as any).key = credential.key;
|
||||||
}
|
}
|
||||||
if (credential.privateKey) {
|
if (credential.privateKey) {
|
||||||
(output as any).privateKey = credential.privateKey;
|
(output as any).privateKey = credential.privateKey;
|
||||||
@@ -337,14 +347,20 @@ router.get("/:id", authenticateJWT, requireDataAccess, async (req: Request, res:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to fetch credential", err);
|
authLogger.error("Failed to fetch credential", err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : "Failed to fetch credential",
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch credential",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update a credential
|
// Update a credential
|
||||||
// PUT /credentials/:id
|
// PUT /credentials/:id
|
||||||
router.put("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.put(
|
||||||
|
"/:id",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updateData = req.body;
|
const updateData = req.body;
|
||||||
@@ -393,9 +409,8 @@ router.put("/:id", authenticateJWT, requireDataAccess, async (req: Request, res:
|
|||||||
updateFields.password = updateData.password || null;
|
updateFields.password = updateData.password || null;
|
||||||
}
|
}
|
||||||
if (updateData.key !== undefined) {
|
if (updateData.key !== undefined) {
|
||||||
updateFields.key = updateData.key || null; // backward compatibility
|
updateFields.key = updateData.key || null;
|
||||||
|
|
||||||
// Parse SSH key if provided
|
|
||||||
if (updateData.key && existing[0].authType === "key") {
|
if (updateData.key && existing[0].authType === "key") {
|
||||||
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||||
if (!keyInfo.success) {
|
if (!keyInfo.success) {
|
||||||
@@ -468,14 +483,20 @@ router.put("/:id", authenticateJWT, requireDataAccess, async (req: Request, res:
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to update credential", err);
|
authLogger.error("Failed to update credential", err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : "Failed to update credential",
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to update credential",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete a credential
|
// Delete a credential
|
||||||
// DELETE /credentials/:id
|
// DELETE /credentials/:id
|
||||||
router.delete("/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => {
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -503,7 +524,10 @@ router.delete("/:id", authenticateJWT, requireDataAccess, async (req: Request, r
|
|||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
.where(
|
.where(
|
||||||
and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
|
and(
|
||||||
|
eq(sshData.credentialId, parseInt(id)),
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hostsUsingCredential.length > 0) {
|
if (hostsUsingCredential.length > 0) {
|
||||||
@@ -559,10 +583,12 @@ router.delete("/:id", authenticateJWT, requireDataAccess, async (req: Request, r
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to delete credential", err);
|
authLogger.error("Failed to delete credential", err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : "Failed to delete credential",
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to delete credential",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Apply a credential to an SSH host (for quick application)
|
// Apply a credential to an SSH host (for quick application)
|
||||||
// POST /credentials/:id/apply-to-host/:hostId
|
// POST /credentials/:id/apply-to-host/:hostId
|
||||||
@@ -892,7 +918,6 @@ router.post(
|
|||||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate SSH keys directly with ssh2
|
|
||||||
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||||
|
|
||||||
if (result.success && result.privateKey && result.publicKey) {
|
if (result.success && result.privateKey && result.publicKey) {
|
||||||
@@ -940,11 +965,9 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First try to create private key object from the input
|
|
||||||
let privateKeyObj;
|
let privateKeyObj;
|
||||||
let parseAttempts = [];
|
let parseAttempts = [];
|
||||||
|
|
||||||
// Attempt 1: Direct parsing with passphrase
|
|
||||||
try {
|
try {
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
key: privateKey,
|
key: privateKey,
|
||||||
@@ -954,7 +977,6 @@ router.post(
|
|||||||
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 2: Direct parsing without passphrase
|
|
||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
try {
|
try {
|
||||||
privateKeyObj = crypto.createPrivateKey(privateKey);
|
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||||
@@ -963,7 +985,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 3: Try with explicit format specification
|
|
||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
try {
|
try {
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
@@ -976,7 +997,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 4: Try as PKCS#1 RSA
|
|
||||||
if (
|
if (
|
||||||
!privateKeyObj &&
|
!privateKeyObj &&
|
||||||
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
|
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
|
||||||
@@ -992,7 +1012,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 5: Try as SEC1 EC
|
|
||||||
if (
|
if (
|
||||||
!privateKeyObj &&
|
!privateKeyObj &&
|
||||||
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
|
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
|
||||||
@@ -1008,7 +1027,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final attempt: Try using ssh2 as fallback
|
|
||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
try {
|
try {
|
||||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
@@ -1038,20 +1056,17 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate public key from private key
|
|
||||||
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
||||||
const publicKeyPem = publicKeyObj.export({
|
const publicKeyPem = publicKeyObj.export({
|
||||||
type: "spki",
|
type: "spki",
|
||||||
format: "pem",
|
format: "pem",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure publicKeyPem is a string
|
|
||||||
const publicKeyString =
|
const publicKeyString =
|
||||||
typeof publicKeyPem === "string"
|
typeof publicKeyPem === "string"
|
||||||
? publicKeyPem
|
? publicKeyPem
|
||||||
: publicKeyPem.toString("utf8");
|
: publicKeyPem.toString("utf8");
|
||||||
|
|
||||||
// Detect key type from the private key object
|
|
||||||
let keyType = "unknown";
|
let keyType = "unknown";
|
||||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||||
|
|
||||||
@@ -1060,12 +1075,10 @@ router.post(
|
|||||||
} else if (asymmetricKeyType === "ed25519") {
|
} else if (asymmetricKeyType === "ed25519") {
|
||||||
keyType = "ssh-ed25519";
|
keyType = "ssh-ed25519";
|
||||||
} else if (asymmetricKeyType === "ec") {
|
} else if (asymmetricKeyType === "ec") {
|
||||||
// For EC keys, we need to check the curve
|
keyType = "ecdsa-sha2-nistp256";
|
||||||
keyType = "ecdsa-sha2-nistp256"; // Default assumption for P-256
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ssh2 to generate SSH format public key
|
let finalPublicKey = publicKeyString;
|
||||||
let finalPublicKey = publicKeyString; // PEM fallback
|
|
||||||
let formatType = "pem";
|
let formatType = "pem";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1076,9 +1089,7 @@ router.post(
|
|||||||
finalPublicKey = `${keyType} ${base64Data}`;
|
finalPublicKey = `${keyType} ${base64Data}`;
|
||||||
formatType = "ssh";
|
formatType = "ssh";
|
||||||
}
|
}
|
||||||
} catch (sshError) {
|
} catch (sshError) {}
|
||||||
// Use PEM format as fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1101,7 +1112,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// SSH Key Deployment Function
|
|
||||||
async function deploySSHKeyToHost(
|
async function deploySSHKeyToHost(
|
||||||
hostConfig: any,
|
hostConfig: any,
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
@@ -1111,7 +1121,6 @@ async function deploySSHKeyToHost(
|
|||||||
const conn = new Client();
|
const conn = new Client();
|
||||||
let connectionTimeout: NodeJS.Timeout;
|
let connectionTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
// Connection timeout
|
|
||||||
connectionTimeout = setTimeout(() => {
|
connectionTimeout = setTimeout(() => {
|
||||||
conn.destroy();
|
conn.destroy();
|
||||||
resolve({ success: false, error: "Connection timeout" });
|
resolve({ success: false, error: "Connection timeout" });
|
||||||
@@ -1121,7 +1130,6 @@ async function deploySSHKeyToHost(
|
|||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create ~/.ssh directory if it doesn't exist
|
|
||||||
await new Promise<void>((resolveCmd, rejectCmd) => {
|
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||||
conn.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh", (err, stream) => {
|
conn.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh", (err, stream) => {
|
||||||
if (err) return rejectCmd(err);
|
if (err) return rejectCmd(err);
|
||||||
@@ -1136,17 +1144,16 @@ async function deploySSHKeyToHost(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Check if public key already exists
|
|
||||||
const keyExists = await new Promise<boolean>(
|
const keyExists = await new Promise<boolean>(
|
||||||
(resolveCheck, rejectCheck) => {
|
(resolveCheck, rejectCheck) => {
|
||||||
const keyPattern = publicKey.split(" ")[1]; // Get the key part without algorithm
|
const keyPattern = publicKey.split(" ")[1];
|
||||||
conn.exec(
|
conn.exec(
|
||||||
`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`,
|
`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`,
|
||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
if (err) return rejectCheck(err);
|
if (err) return rejectCheck(err);
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
resolveCheck(code === 0); // code 0 means key found
|
resolveCheck(code === 0);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1159,7 +1166,6 @@ async function deploySSHKeyToHost(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Add public key to authorized_keys
|
|
||||||
await new Promise<void>((resolveAdd, rejectAdd) => {
|
await new Promise<void>((resolveAdd, rejectAdd) => {
|
||||||
const escapedKey = publicKey.replace(/'/g, "'\\''");
|
const escapedKey = publicKey.replace(/'/g, "'\\''");
|
||||||
conn.exec(
|
conn.exec(
|
||||||
@@ -1180,7 +1186,6 @@ async function deploySSHKeyToHost(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Verify deployment
|
|
||||||
const verifySuccess = await new Promise<boolean>(
|
const verifySuccess = await new Promise<boolean>(
|
||||||
(resolveVerify, rejectVerify) => {
|
(resolveVerify, rejectVerify) => {
|
||||||
const keyPattern = publicKey.split(" ")[1];
|
const keyPattern = publicKey.split(" ")[1];
|
||||||
@@ -1221,7 +1226,6 @@ async function deploySSHKeyToHost(
|
|||||||
resolve({ success: false, error: err.message });
|
resolve({ success: false, error: err.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to the target host
|
|
||||||
try {
|
try {
|
||||||
const connectionConfig: any = {
|
const connectionConfig: any = {
|
||||||
host: hostConfig.ip,
|
host: hostConfig.ip,
|
||||||
@@ -1272,7 +1276,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get credential details
|
|
||||||
const credential = await db
|
const credential = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshCredentials)
|
.from(sshCredentials)
|
||||||
@@ -1288,7 +1291,6 @@ router.post(
|
|||||||
|
|
||||||
const credData = credential[0];
|
const credData = credential[0];
|
||||||
|
|
||||||
// Only support key-based credentials for deployment
|
|
||||||
if (credData.authType !== "key") {
|
if (credData.authType !== "key") {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1303,7 +1305,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get target host details
|
|
||||||
const targetHost = await db
|
const targetHost = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
@@ -1319,7 +1320,6 @@ router.post(
|
|||||||
|
|
||||||
const hostData = targetHost[0];
|
const hostData = targetHost[0];
|
||||||
|
|
||||||
// Prepare host configuration for connection
|
|
||||||
let hostConfig = {
|
let hostConfig = {
|
||||||
ip: hostData.ip,
|
ip: hostData.ip,
|
||||||
port: hostData.port,
|
port: hostData.port,
|
||||||
@@ -1330,7 +1330,6 @@ router.post(
|
|||||||
keyPassword: hostData.keyPassword,
|
keyPassword: hostData.keyPassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If host uses credential authentication, resolve the credential
|
|
||||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||||
const hostCredential = await db
|
const hostCredential = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -1341,14 +1340,13 @@ router.post(
|
|||||||
if (hostCredential && hostCredential.length > 0) {
|
if (hostCredential && hostCredential.length > 0) {
|
||||||
const cred = hostCredential[0];
|
const cred = hostCredential[0];
|
||||||
|
|
||||||
// Update hostConfig with credential data
|
|
||||||
hostConfig.authType = cred.authType;
|
hostConfig.authType = cred.authType;
|
||||||
hostConfig.username = cred.username; // Use credential's username
|
hostConfig.username = cred.username;
|
||||||
|
|
||||||
if (cred.authType === "password") {
|
if (cred.authType === "password") {
|
||||||
hostConfig.password = cred.password;
|
hostConfig.password = cred.password;
|
||||||
} else if (cred.authType === "key") {
|
} else if (cred.authType === "key") {
|
||||||
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
|
hostConfig.privateKey = cred.privateKey || cred.key;
|
||||||
hostConfig.keyPassword = cred.keyPassword;
|
hostConfig.keyPassword = cred.keyPassword;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1359,7 +1357,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy the SSH key
|
|
||||||
const deployResult = await deploySSHKeyToHost(
|
const deployResult = await deploySSHKeyToHost(
|
||||||
hostConfig,
|
hostConfig,
|
||||||
credData.publicKey,
|
credData.publicKey,
|
||||||
@@ -1367,8 +1364,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (deployResult.success) {
|
if (deployResult.success) {
|
||||||
// Log successful deployment
|
authLogger.success(`SSH key deployed successfully`, {
|
||||||
authLogger.info(`SSH key deployed successfully`, {
|
|
||||||
credentialId,
|
credentialId,
|
||||||
targetHostId,
|
targetHostId,
|
||||||
operation: "deploy_ssh_key",
|
operation: "deploy_ssh_key",
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ const router = express.Router();
|
|||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
interface JWTPayload {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNonEmptyString(value: any): value is string {
|
function isNonEmptyString(value: any): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -35,26 +31,25 @@ function isValidPort(port: any): port is number {
|
|||||||
return typeof port === "number" && port > 0 && port <= 65535;
|
return typeof port === "number" && port > 0 && port <= 65535;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AuthManager middleware for authentication
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
|
|
||||||
// Internal-only endpoint for autostart - requires internal auth token
|
|
||||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Check for internal authentication token using SystemCrypto
|
|
||||||
const internalToken = req.headers["x-internal-auth-token"];
|
const internalToken = req.headers["x-internal-auth-token"];
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||||
|
|
||||||
if (internalToken !== expectedToken) {
|
if (internalToken !== expectedToken) {
|
||||||
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint", {
|
sshLogger.warn(
|
||||||
|
"Unauthorized attempt to access internal SSH host endpoint",
|
||||||
|
{
|
||||||
source: req.ip,
|
source: req.ip,
|
||||||
userAgent: req.headers["user-agent"],
|
userAgent: req.headers["user-agent"],
|
||||||
providedToken: internalToken ? "present" : "missing"
|
providedToken: internalToken ? "present" : "missing",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,25 +58,16 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query sshData directly for hosts that have autostart plaintext fields populated
|
const autostartHosts = await db
|
||||||
const autostartHosts = await db.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
.where(
|
.where(
|
||||||
// Check if any autostart fields are populated (meaning autostart is enabled)
|
|
||||||
or(
|
or(
|
||||||
isNotNull(sshData.autostartPassword),
|
isNotNull(sshData.autostartPassword),
|
||||||
isNotNull(sshData.autostartKey)
|
isNotNull(sshData.autostartKey),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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 result = autostartHosts.map((host) => {
|
||||||
const tunnelConnections = host.tunnelConnections
|
const tunnelConnections = host.tunnelConnections
|
||||||
? JSON.parse(host.tunnelConnections)
|
? JSON.parse(host.tunnelConnections)
|
||||||
@@ -97,13 +83,14 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
password: host.autostartPassword,
|
password: host.autostartPassword,
|
||||||
key: host.autostartKey,
|
key: host.autostartKey,
|
||||||
keyPassword: host.autostartKeyPassword,
|
keyPassword: host.autostartKeyPassword,
|
||||||
// Include explicit autostart fields for tunnel service
|
|
||||||
autostartPassword: host.autostartPassword,
|
autostartPassword: host.autostartPassword,
|
||||||
autostartKey: host.autostartKey,
|
autostartKey: host.autostartKey,
|
||||||
autostartKeyPassword: host.autostartKeyPassword,
|
autostartKeyPassword: host.autostartKeyPassword,
|
||||||
authType: host.authType,
|
authType: host.authType,
|
||||||
enableTunnel: true,
|
enableTunnel: true,
|
||||||
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
|
tunnelConnections: tunnelConnections.filter(
|
||||||
|
(tunnel: any) => tunnel.autoStart,
|
||||||
|
),
|
||||||
pin: false,
|
pin: false,
|
||||||
enableTerminal: false,
|
enableTerminal: false,
|
||||||
enableFileManager: false,
|
enableFileManager: false,
|
||||||
@@ -118,33 +105,26 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) => {
|
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Check for internal authentication token using SystemCrypto
|
|
||||||
const internalToken = req.headers["x-internal-auth-token"];
|
const internalToken = req.headers["x-internal-auth-token"];
|
||||||
if (!internalToken) {
|
if (!internalToken) {
|
||||||
return res.status(401).json({ error: "Internal authentication token required" });
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Internal authentication token required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||||
|
|
||||||
if (internalToken !== expectedToken) {
|
if (internalToken !== expectedToken) {
|
||||||
return res.status(401).json({ error: "Invalid internal authentication token" });
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Invalid internal authentication token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query all hosts for endpointHost resolution
|
|
||||||
const allHosts = await db.select().from(sshData);
|
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 result = allHosts.map((host) => {
|
||||||
const tunnelConnections = host.tunnelConnections
|
const tunnelConnections = host.tunnelConnections
|
||||||
? JSON.parse(host.tunnelConnections)
|
? JSON.parse(host.tunnelConnections)
|
||||||
@@ -160,7 +140,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
|||||||
password: host.autostartPassword || host.password,
|
password: host.autostartPassword || host.password,
|
||||||
key: host.autostartKey || host.key,
|
key: host.autostartKey || host.key,
|
||||||
keyPassword: host.autostartKeyPassword || host.keyPassword,
|
keyPassword: host.autostartKeyPassword || host.keyPassword,
|
||||||
// Include autostart fields for fallback
|
|
||||||
autostartPassword: host.autostartPassword,
|
autostartPassword: host.autostartPassword,
|
||||||
autostartKey: host.autostartKey,
|
autostartKey: host.autostartKey,
|
||||||
autostartKeyPassword: host.autostartKeyPassword,
|
autostartKeyPassword: host.autostartKeyPassword,
|
||||||
@@ -291,7 +270,6 @@ router.post(
|
|||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
} else {
|
} else {
|
||||||
// For credential auth
|
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
@@ -1381,103 +1359,116 @@ router.post(
|
|||||||
const { sshConfigId } = req.body;
|
const { sshConfigId } = req.body;
|
||||||
|
|
||||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||||
sshLogger.warn("Missing or invalid sshConfigId in autostart enable request", {
|
sshLogger.warn(
|
||||||
|
"Missing or invalid sshConfigId in autostart enable request",
|
||||||
|
{
|
||||||
operation: "autostart_enable",
|
operation: "autostart_enable",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate user has access to decrypt the data
|
|
||||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
sshLogger.warn("User attempted to enable autostart without unlocked data", {
|
sshLogger.warn(
|
||||||
|
"User attempted to enable autostart without unlocked data",
|
||||||
|
{
|
||||||
operation: "autostart_enable_failed",
|
operation: "autostart_enable_failed",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId,
|
sshConfigId,
|
||||||
reason: "data_locked"
|
reason: "data_locked",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Failed to enable autostart. Ensure user data is unlocked."
|
error: "Failed to enable autostart. Ensure user data is unlocked.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get and decrypt SSH configuration
|
const sshConfig = await db
|
||||||
const sshConfig = await db.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
.where(and(
|
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
||||||
eq(sshData.id, sshConfigId),
|
|
||||||
eq(sshData.userId, userId)
|
|
||||||
));
|
|
||||||
|
|
||||||
if (sshConfig.length === 0) {
|
if (sshConfig.length === 0) {
|
||||||
sshLogger.warn("SSH config not found for autostart enable", {
|
sshLogger.warn("SSH config not found for autostart enable", {
|
||||||
operation: "autostart_enable_failed",
|
operation: "autostart_enable_failed",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId,
|
sshConfigId,
|
||||||
reason: "config_not_found"
|
reason: "config_not_found",
|
||||||
});
|
});
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: "SSH configuration not found"
|
error: "SSH configuration not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = sshConfig[0];
|
const config = sshConfig[0];
|
||||||
|
|
||||||
// Decrypt sensitive fields
|
const decryptedConfig = DataCrypto.decryptRecord(
|
||||||
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
"ssh_data",
|
||||||
|
config,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
|
|
||||||
// Also handle tunnel connections - populate endpoint credentials
|
|
||||||
let updatedTunnelConnections = config.tunnelConnections;
|
let updatedTunnelConnections = config.tunnelConnections;
|
||||||
if (config.tunnelConnections) {
|
if (config.tunnelConnections) {
|
||||||
try {
|
try {
|
||||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||||
|
|
||||||
// For each tunnel connection, try to resolve endpoint credentials
|
|
||||||
const resolvedConnections = await Promise.all(
|
const resolvedConnections = await Promise.all(
|
||||||
tunnelConnections.map(async (tunnel: any) => {
|
tunnelConnections.map(async (tunnel: any) => {
|
||||||
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
if (
|
||||||
// Find endpoint host by name or username@ip
|
tunnel.autoStart &&
|
||||||
const endpointHosts = await db.select()
|
tunnel.endpointHost &&
|
||||||
|
!tunnel.endpointPassword &&
|
||||||
|
!tunnel.endpointKey
|
||||||
|
) {
|
||||||
|
const endpointHosts = await db
|
||||||
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
.where(eq(sshData.userId, userId));
|
.where(eq(sshData.userId, userId));
|
||||||
|
|
||||||
const endpointHost = endpointHosts.find(h =>
|
const endpointHost = endpointHosts.find(
|
||||||
|
(h) =>
|
||||||
h.name === tunnel.endpointHost ||
|
h.name === tunnel.endpointHost ||
|
||||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (endpointHost) {
|
if (endpointHost) {
|
||||||
// Decrypt endpoint host credentials
|
const decryptedEndpoint = DataCrypto.decryptRecord(
|
||||||
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
"ssh_data",
|
||||||
|
endpointHost,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
|
|
||||||
// Add endpoint credentials to tunnel connection
|
|
||||||
return {
|
return {
|
||||||
...tunnel,
|
...tunnel,
|
||||||
endpointPassword: decryptedEndpoint.password || null,
|
endpointPassword: decryptedEndpoint.password || null,
|
||||||
endpointKey: decryptedEndpoint.key || null,
|
endpointKey: decryptedEndpoint.key || null,
|
||||||
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
|
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
|
||||||
endpointAuthType: endpointHost.authType
|
endpointAuthType: endpointHost.authType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tunnel;
|
return tunnel;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.warn("Failed to update tunnel connections", {
|
sshLogger.warn("Failed to update tunnel connections", {
|
||||||
operation: "tunnel_connections_update_failed",
|
operation: "tunnel_connections_update_failed",
|
||||||
error: error instanceof Error ? error.message : "Unknown error"
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the SSH config with plaintext autostart fields and resolved tunnel connections
|
const updateResult = await db
|
||||||
const updateResult = await db.update(sshData)
|
.update(sshData)
|
||||||
.set({
|
.set({
|
||||||
autostartPassword: decryptedConfig.password || null,
|
autostartPassword: decryptedConfig.password || null,
|
||||||
autostartKey: decryptedConfig.key || null,
|
autostartKey: decryptedConfig.key || null,
|
||||||
@@ -1486,36 +1477,29 @@ router.post(
|
|||||||
})
|
})
|
||||||
.where(eq(sshData.id, sshConfigId));
|
.where(eq(sshData.id, sshConfigId));
|
||||||
|
|
||||||
// Force database save after autostart update
|
|
||||||
try {
|
try {
|
||||||
await DatabaseSaveTrigger.triggerSave();
|
await DatabaseSaveTrigger.triggerSave();
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
sshLogger.warn("Database save failed after autostart", {
|
sshLogger.warn("Database save failed after autostart", {
|
||||||
operation: "autostart_db_save_failed",
|
operation: "autostart_db_save_failed",
|
||||||
error: saveError instanceof Error ? saveError.message : "Unknown error"
|
error:
|
||||||
|
saveError instanceof Error ? saveError.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sshLogger.success("AutoStart enabled successfully", {
|
|
||||||
operation: "autostart_enabled",
|
|
||||||
userId,
|
|
||||||
sshConfigId,
|
|
||||||
host: config.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "AutoStart enabled successfully",
|
message: "AutoStart enabled successfully",
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Error enabling autostart", error, {
|
sshLogger.error("Error enabling autostart", error, {
|
||||||
operation: "autostart_enable_error",
|
operation: "autostart_enable_error",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route: Disable autostart for SSH configuration (requires JWT)
|
// Route: Disable autostart for SSH configuration (requires JWT)
|
||||||
@@ -1528,46 +1512,40 @@ router.delete(
|
|||||||
const { sshConfigId } = req.body;
|
const { sshConfigId } = req.body;
|
||||||
|
|
||||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||||
sshLogger.warn("Missing or invalid sshConfigId in autostart disable request", {
|
sshLogger.warn(
|
||||||
|
"Missing or invalid sshConfigId in autostart disable request",
|
||||||
|
{
|
||||||
operation: "autostart_disable",
|
operation: "autostart_disable",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clear the autostart plaintext fields for this SSH config
|
const result = await db
|
||||||
const result = await db.update(sshData)
|
.update(sshData)
|
||||||
.set({
|
.set({
|
||||||
autostartPassword: null,
|
autostartPassword: null,
|
||||||
autostartKey: null,
|
autostartKey: null,
|
||||||
autostartKeyPassword: null,
|
autostartKeyPassword: null,
|
||||||
})
|
})
|
||||||
.where(and(
|
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
||||||
eq(sshData.id, sshConfigId),
|
|
||||||
eq(sshData.userId, userId)
|
|
||||||
));
|
|
||||||
|
|
||||||
sshLogger.info("AutoStart disabled successfully", {
|
|
||||||
operation: "autostart_disabled",
|
|
||||||
userId,
|
|
||||||
sshConfigId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "AutoStart disabled successfully",
|
message: "AutoStart disabled successfully",
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Error disabling autostart", error, {
|
sshLogger.error("Error disabling autostart", error, {
|
||||||
operation: "autostart_disable_error",
|
operation: "autostart_disable_error",
|
||||||
userId,
|
userId,
|
||||||
sshConfigId
|
sshConfigId,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route: Get autostart status for user's SSH configurations (requires JWT)
|
// Route: Get autostart status for user's SSH configurations (requires JWT)
|
||||||
@@ -1579,44 +1557,39 @@ router.get(
|
|||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query user's SSH configs that have autostart enabled
|
const autostartConfigs = await db
|
||||||
const autostartConfigs = await db.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
.where(and(
|
.where(
|
||||||
|
and(
|
||||||
eq(sshData.userId, userId),
|
eq(sshData.userId, userId),
|
||||||
or(
|
or(
|
||||||
isNotNull(sshData.autostartPassword),
|
isNotNull(sshData.autostartPassword),
|
||||||
isNotNull(sshData.autostartKey)
|
isNotNull(sshData.autostartKey),
|
||||||
)
|
),
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Map to just the basic info needed for status
|
const statusList = autostartConfigs.map((config) => ({
|
||||||
const statusList = autostartConfigs.map(config => ({
|
|
||||||
sshConfigId: config.id,
|
sshConfigId: config.id,
|
||||||
host: config.ip,
|
host: config.ip,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
username: config.username,
|
username: config.username,
|
||||||
authType: config.authType
|
authType: config.authType,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
sshLogger.info("AutoStart status retrieved", {
|
|
||||||
operation: "autostart_status",
|
|
||||||
userId,
|
|
||||||
configCount: statusList.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
autostart_configs: statusList,
|
autostart_configs: statusList,
|
||||||
total_count: statusList.length
|
total_count: statusList.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Error getting autostart status", error, {
|
sshLogger.error("Error getting autostart status", error, {
|
||||||
operation: "autostart_status_error",
|
operation: "autostart_status_error",
|
||||||
userId
|
userId,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -13,16 +13,13 @@ import {
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import speakeasy from "speakeasy";
|
import speakeasy from "speakeasy";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { authLogger, apiLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
|
||||||
// Get auth manager instance
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
async function verifyOIDCToken(
|
async function verifyOIDCToken(
|
||||||
@@ -137,11 +134,8 @@ interface JWTPayload {
|
|||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT authentication middleware - only verify JWT, no data unlock required
|
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireAdmin = authManager.createAdminMiddleware();
|
const requireAdmin = authManager.createAdminMiddleware();
|
||||||
|
|
||||||
// Data access middleware - requires user to have unlocked data keys
|
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
// Route: Create traditional user (username/password)
|
// Route: Create traditional user (username/password)
|
||||||
@@ -220,22 +214,20 @@ router.post("/create", async (req, res) => {
|
|||||||
totp_backup_codes: null,
|
totp_backup_codes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up user data encryption (KEK-DEK architecture)
|
|
||||||
try {
|
try {
|
||||||
await authManager.registerUser(id, password);
|
await authManager.registerUser(id, password);
|
||||||
authLogger.success("User encryption setup completed", {
|
|
||||||
operation: "user_encryption_setup",
|
|
||||||
userId: id,
|
|
||||||
});
|
|
||||||
} catch (encryptionError) {
|
} catch (encryptionError) {
|
||||||
// If encryption setup fails, delete user record
|
|
||||||
await db.delete(users).where(eq(users.id, id));
|
await db.delete(users).where(eq(users.id, id));
|
||||||
authLogger.error("Failed to setup user encryption, user creation rolled back", encryptionError, {
|
authLogger.error(
|
||||||
|
"Failed to setup user encryption, user creation rolled back",
|
||||||
|
encryptionError,
|
||||||
|
{
|
||||||
operation: "user_create_encryption_failed",
|
operation: "user_create_encryption_failed",
|
||||||
userId: id,
|
userId: id,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "Failed to setup user security - user creation cancelled"
|
error: "Failed to setup user security - user creation cancelled",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,38 +330,46 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
|||||||
scopes: scopes || "openid email profile",
|
scopes: scopes || "openid email profile",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encrypt sensitive configuration for storage
|
|
||||||
let encryptedConfig;
|
let encryptedConfig;
|
||||||
try {
|
try {
|
||||||
// Use admin's data key to encrypt OIDC configuration
|
|
||||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (adminDataKey) {
|
if (adminDataKey) {
|
||||||
// Provide stable recordId for settings objects
|
|
||||||
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
||||||
encryptedConfig = DataCrypto.encryptRecord("settings", configWithId, userId, adminDataKey);
|
encryptedConfig = DataCrypto.encryptRecord(
|
||||||
|
"settings",
|
||||||
|
configWithId,
|
||||||
|
userId,
|
||||||
|
adminDataKey,
|
||||||
|
);
|
||||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
authLogger.info("OIDC configuration encrypted with admin data key", {
|
||||||
operation: "oidc_config_encrypt",
|
operation: "oidc_config_encrypt",
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If admin data not unlocked, only encrypt client_secret
|
|
||||||
encryptedConfig = {
|
encryptedConfig = {
|
||||||
...config,
|
...config,
|
||||||
client_secret: `encrypted:${Buffer.from(client_secret).toString('base64')}`, // Simple base64 encoding
|
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", {
|
authLogger.warn(
|
||||||
|
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||||
|
{
|
||||||
operation: "oidc_config_basic_encoding",
|
operation: "oidc_config_basic_encoding",
|
||||||
userId,
|
userId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (encryptError) {
|
} catch (encryptError) {
|
||||||
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, {
|
authLogger.error(
|
||||||
|
"Failed to encrypt OIDC configuration, storing with basic encoding",
|
||||||
|
encryptError,
|
||||||
|
{
|
||||||
operation: "oidc_config_encrypt_failed",
|
operation: "oidc_config_encrypt_failed",
|
||||||
userId,
|
userId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
encryptedConfig = {
|
encryptedConfig = {
|
||||||
...config,
|
...config,
|
||||||
client_secret: `encoded:${Buffer.from(client_secret).toString('base64')}`,
|
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,10 +426,8 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
let config = 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) {
|
||||||
if (config.client_secret.startsWith('encrypted:')) {
|
if (config.client_secret.startsWith("encrypted:")) {
|
||||||
// Requires admin permission to decrypt
|
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.split(" ")[1];
|
const token = authHeader.split(" ")[1];
|
||||||
@@ -438,16 +436,22 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const userId = payload.userId;
|
const userId = payload.userId;
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
if (user && user.length > 0 && user[0].is_admin) {
|
if (user && user.length > 0 && user[0].is_admin) {
|
||||||
try {
|
try {
|
||||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (adminDataKey) {
|
if (adminDataKey) {
|
||||||
// Use same stable recordId for decryption - note: FieldCrypto will use stored recordId
|
config = DataCrypto.decryptRecord(
|
||||||
config = DataCrypto.decryptRecord("settings", config, userId, adminDataKey);
|
"settings",
|
||||||
|
config,
|
||||||
|
userId,
|
||||||
|
adminDataKey,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Admin data not unlocked, hide client_secret
|
|
||||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||||
}
|
}
|
||||||
} catch (decryptError) {
|
} catch (decryptError) {
|
||||||
@@ -466,16 +470,17 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||||
}
|
}
|
||||||
} else if (config.client_secret.startsWith('encoded:')) {
|
} else if (config.client_secret.startsWith("encoded:")) {
|
||||||
// base64 decode
|
|
||||||
try {
|
try {
|
||||||
const decoded = Buffer.from(config.client_secret.substring(8), 'base64').toString('utf8');
|
const decoded = Buffer.from(
|
||||||
|
config.client_secret.substring(8),
|
||||||
|
"base64",
|
||||||
|
).toString("utf8");
|
||||||
config.client_secret = decoded;
|
config.client_secret = decoded;
|
||||||
} catch {
|
} catch {
|
||||||
config.client_secret = "[ENCODING ERROR]";
|
config.client_secret = "[ENCODING ERROR]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise plaintext, return directly
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(config);
|
res.json(config);
|
||||||
@@ -788,7 +793,11 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
redirectUrl.searchParams.set("success", "true");
|
redirectUrl.searchParams.set("success", "true");
|
||||||
|
|
||||||
return res
|
return res
|
||||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000))
|
.cookie(
|
||||||
|
"jwt",
|
||||||
|
token,
|
||||||
|
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
.redirect(redirectUrl.toString());
|
.redirect(redirectUrl.toString());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("OIDC callback failed", err);
|
authLogger.error("OIDC callback failed", err);
|
||||||
@@ -857,7 +866,6 @@ router.post("/login", async (req, res) => {
|
|||||||
return res.status(401).json({ error: "Incorrect password" });
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if legacy user needs encryption setup
|
|
||||||
try {
|
try {
|
||||||
const kekSalt = await db
|
const kekSalt = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -865,13 +873,7 @@ router.post("/login", async (req, res) => {
|
|||||||
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
|
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
|
||||||
|
|
||||||
if (kekSalt.length === 0) {
|
if (kekSalt.length === 0) {
|
||||||
// Legacy user first login - set up new encryption
|
|
||||||
await authManager.registerUser(userRecord.id, password);
|
await authManager.registerUser(userRecord.id, password);
|
||||||
authLogger.success("Legacy user encryption initialized", {
|
|
||||||
operation: "legacy_user_setup",
|
|
||||||
username,
|
|
||||||
userId: userRecord.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (setupError) {
|
} catch (setupError) {
|
||||||
authLogger.error("Failed to initialize user encryption", setupError, {
|
authLogger.error("Failed to initialize user encryption", setupError, {
|
||||||
@@ -879,11 +881,12 @@ router.post("/login", async (req, res) => {
|
|||||||
username,
|
username,
|
||||||
userId: userRecord.id,
|
userId: userRecord.id,
|
||||||
});
|
});
|
||||||
// Encryption setup failure should not block login for existing users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock user data keys
|
const dataUnlocked = await authManager.authenticateUser(
|
||||||
const dataUnlocked = await authManager.authenticateUser(userRecord.id, password);
|
userRecord.id,
|
||||||
|
password,
|
||||||
|
);
|
||||||
if (!dataUnlocked) {
|
if (!dataUnlocked) {
|
||||||
authLogger.error("Failed to unlock user data during login", undefined, {
|
authLogger.error("Failed to unlock user data during login", undefined, {
|
||||||
operation: "user_login_data_unlock_failed",
|
operation: "user_login_data_unlock_failed",
|
||||||
@@ -891,11 +894,10 @@ router.post("/login", async (req, res) => {
|
|||||||
userId: userRecord.id,
|
userId: userRecord.id,
|
||||||
});
|
});
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "Failed to unlock user data - please contact administrator"
|
error: "Failed to unlock user data - please contact administrator",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOTP handling
|
|
||||||
if (userRecord.totp_enabled) {
|
if (userRecord.totp_enabled) {
|
||||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||||
pendingTOTP: true,
|
pendingTOTP: true,
|
||||||
@@ -907,7 +909,6 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate normal JWT token
|
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
expiresIn: "24h",
|
expiresIn: "24h",
|
||||||
});
|
});
|
||||||
@@ -920,7 +921,11 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return res
|
return res
|
||||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
|
.cookie(
|
||||||
|
"jwt",
|
||||||
|
token,
|
||||||
|
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
.json({
|
.json({
|
||||||
success: true,
|
success: true,
|
||||||
is_admin: !!userRecord.is_admin,
|
is_admin: !!userRecord.is_admin,
|
||||||
@@ -936,11 +941,9 @@ router.post("/login", async (req, res) => {
|
|||||||
// POST /users/logout
|
// POST /users/logout
|
||||||
router.post("/logout", async (req, res) => {
|
router.post("/logout", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Try to get userId from JWT if available
|
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
// User is authenticated - clear data session
|
|
||||||
authManager.logoutUser(userId);
|
authManager.logoutUser(userId);
|
||||||
authLogger.info("User logged out", {
|
authLogger.info("User logged out", {
|
||||||
operation: "user_logout",
|
operation: "user_logout",
|
||||||
@@ -948,7 +951,6 @@ router.post("/logout", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always clear the JWT cookie
|
|
||||||
return res
|
return res
|
||||||
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
|
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
|
||||||
.json({ success: true, message: "Logged out successfully" });
|
.json({ success: true, message: "Logged out successfully" });
|
||||||
@@ -973,7 +975,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
return res.status(401).json({ error: "User not found" });
|
return res.status(401).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user data is unlocked
|
|
||||||
const isDataUnlocked = authManager.isUserUnlocked(userId);
|
const isDataUnlocked = authManager.isUserUnlocked(userId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1001,7 +1002,6 @@ router.get("/setup-required", async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
setup_required: count === 0,
|
setup_required: count === 0,
|
||||||
// 不暴露具体用户数量,只返回是否需要初始化
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to check setup status", err);
|
authLogger.error("Failed to check setup status", err);
|
||||||
@@ -1014,7 +1014,6 @@ router.get("/setup-required", async (req, res) => {
|
|||||||
router.get("/count", authenticateJWT, async (req, res) => {
|
router.get("/count", authenticateJWT, async (req, res) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
try {
|
try {
|
||||||
// 只有管理员可以查看用户统计
|
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
if (!user[0] || !user[0].is_admin) {
|
if (!user[0] || !user[0].is_admin) {
|
||||||
return res.status(403).json({ error: "Admin access required" });
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
@@ -1282,14 +1281,15 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Invalid temporary token" });
|
return res.status(400).json({ error: "Invalid temporary token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user ID for KEK-DEK operations
|
const user = await db
|
||||||
const user = await db.select().from(users).where(eq(users.username, username));
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
const userId = user[0].id;
|
const userId = user[0].id;
|
||||||
|
|
||||||
// Update password hash
|
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
@@ -1490,7 +1490,11 @@ router.post("/totp/verify-login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return res
|
return res
|
||||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000))
|
.cookie(
|
||||||
|
"jwt",
|
||||||
|
token,
|
||||||
|
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
.json({
|
.json({
|
||||||
success: true,
|
success: true,
|
||||||
is_admin: !!userRecord.is_admin,
|
is_admin: !!userRecord.is_admin,
|
||||||
@@ -1802,8 +1806,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== New security API endpoints =====
|
|
||||||
|
|
||||||
// Route: User data unlock - used when session expires
|
// Route: User data unlock - used when session expires
|
||||||
// POST /users/unlock-data
|
// POST /users/unlock-data
|
||||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||||
@@ -1817,13 +1819,9 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const unlocked = await authManager.authenticateUser(userId, password);
|
const unlocked = await authManager.authenticateUser(userId, password);
|
||||||
if (unlocked) {
|
if (unlocked) {
|
||||||
authLogger.success("User data unlocked", {
|
|
||||||
operation: "user_data_unlock",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Data unlocked successfully"
|
message: "Data unlocked successfully",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
authLogger.warn("Failed to unlock user data - invalid password", {
|
authLogger.warn("Failed to unlock user data - invalid password", {
|
||||||
@@ -1850,7 +1848,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
|||||||
const isUnlocked = authManager.isUserUnlocked(userId);
|
const isUnlocked = authManager.isUserUnlocked(userId);
|
||||||
res.json({
|
res.json({
|
||||||
unlocked: isUnlocked,
|
unlocked: isUnlocked,
|
||||||
message: isUnlocked ? "Data is unlocked" : "Data is locked - re-authenticate with password"
|
message: isUnlocked
|
||||||
|
? "Data is unlocked"
|
||||||
|
: "Data is locked - re-authenticate with password",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to check data status", err, {
|
authLogger.error("Failed to check data status", err, {
|
||||||
@@ -1869,26 +1869,24 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
if (!currentPassword || !newPassword) {
|
if (!currentPassword || !newPassword) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Current password and new password are required"
|
error: "Current password and new password are required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "New password must be at least 8 characters long"
|
error: "New password must be at least 8 characters long",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify current password and change
|
|
||||||
const success = await authManager.changeUserPassword(
|
const success = await authManager.changeUserPassword(
|
||||||
userId,
|
userId,
|
||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword
|
newPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Also update password hash in database
|
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
await db
|
await db
|
||||||
@@ -1903,7 +1901,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Password changed successfully"
|
message: "Password changed successfully",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
authLogger.warn("Password change failed - invalid current password", {
|
authLogger.warn("Password change failed - invalid current password", {
|
||||||
|
|||||||
+60
-159
@@ -9,13 +9,10 @@ import { fileLogger } from "../utils/logger.js";
|
|||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
|
||||||
// Executable file detection utility function
|
|
||||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||||
// Check execute permission bits (user, group, other)
|
|
||||||
const hasExecutePermission =
|
const hasExecutePermission =
|
||||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||||
|
|
||||||
// Common script file extensions
|
|
||||||
const scriptExtensions = [
|
const scriptExtensions = [
|
||||||
".sh",
|
".sh",
|
||||||
".py",
|
".py",
|
||||||
@@ -31,13 +28,11 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
|
|||||||
fileName.toLowerCase().endsWith(ext),
|
fileName.toLowerCase().endsWith(ext),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Common compiled executable files (no extension or specific extensions)
|
|
||||||
const executableExtensions = [".bin", ".exe", ".out"];
|
const executableExtensions = [".bin", ".exe", ".out"];
|
||||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||||
fileName.toLowerCase().endsWith(ext),
|
fileName.toLowerCase().endsWith(ext),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Files with no extension and execute permission are usually executable files
|
|
||||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,33 +46,27 @@ const app = express();
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
// Allow requests with no origin (like mobile apps or curl requests)
|
|
||||||
if (!origin) return callback(null, true);
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
// Allow localhost and 127.0.0.1 for development
|
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Allow any HTTPS origin (production deployments)
|
|
||||||
if (origin.startsWith("https://")) {
|
if (origin.startsWith("https://")) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow any HTTP origin for self-hosted scenarios
|
|
||||||
if (origin.startsWith("http://")) {
|
if (origin.startsWith("http://")) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check against allowed development origins
|
|
||||||
if (allowedOrigins.includes(origin)) {
|
if (allowedOrigins.includes(origin)) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject other origins
|
|
||||||
callback(new Error("Not allowed by CORS"));
|
callback(new Error("Not allowed by CORS"));
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -95,7 +84,6 @@ app.use(express.json({ limit: "1gb" }));
|
|||||||
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
||||||
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||||
|
|
||||||
// Initialize AuthManager and add authentication middleware
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
@@ -122,16 +110,37 @@ function cleanupSession(sessionId: string) {
|
|||||||
function scheduleSessionCleanup(sessionId: string) {
|
function scheduleSessionCleanup(sessionId: string) {
|
||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
if (session) {
|
if (session) {
|
||||||
// Clear existing timeout
|
|
||||||
if (session.timeout) clearTimeout(session.timeout);
|
if (session.timeout) clearTimeout(session.timeout);
|
||||||
|
|
||||||
// Increase timeout to 30 minutes of inactivity
|
session.timeout = setTimeout(
|
||||||
session.timeout = setTimeout(() => {
|
() => {
|
||||||
cleanupSession(sessionId);
|
cleanupSession(sessionId);
|
||||||
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
},
|
||||||
|
30 * 60 * 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMimeType(fileName: string): string {
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
txt: "text/plain",
|
||||||
|
json: "application/json",
|
||||||
|
js: "text/javascript",
|
||||||
|
html: "text/html",
|
||||||
|
css: "text/css",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
zip: "application/zip",
|
||||||
|
tar: "application/x-tar",
|
||||||
|
gz: "application/gzip",
|
||||||
|
};
|
||||||
|
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||||
const {
|
const {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -146,7 +155,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
credentialId,
|
credentialId,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Use authenticated user ID from middleware
|
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -194,7 +202,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
sshKey: credential.privateKey || credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
};
|
};
|
||||||
@@ -255,7 +263,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
"3des-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"],
|
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"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -352,7 +367,6 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
|||||||
res.json({ status: "success", connected: isConnected });
|
res.json({ status: "success", connected: isConnected });
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSH keepalive endpoint - extends session timeout and verifies connection
|
|
||||||
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||||
const { sessionId } = req.body;
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
@@ -365,11 +379,10 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
|||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "SSH session not found or not connected",
|
error: "SSH session not found or not connected",
|
||||||
connected: false
|
connected: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last active time and reschedule cleanup
|
|
||||||
session.lastActive = Date.now();
|
session.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
@@ -377,7 +390,7 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
|||||||
status: "success",
|
status: "success",
|
||||||
connected: true,
|
connected: true,
|
||||||
message: "Session keepalive successful",
|
message: "Session keepalive successful",
|
||||||
lastActive: session.lastActive
|
lastActive: session.lastActive,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,12 +448,10 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
const group = parts[3];
|
const group = parts[3];
|
||||||
const size = parseInt(parts[4], 10);
|
const size = parseInt(parts[4], 10);
|
||||||
|
|
||||||
// Date may occupy 3 parts (month day time) or (month day year)
|
|
||||||
let dateStr = "";
|
let dateStr = "";
|
||||||
let nameStartIndex = 8;
|
let nameStartIndex = 8;
|
||||||
|
|
||||||
if (parts[5] && parts[6] && parts[7]) {
|
if (parts[5] && parts[6] && parts[7]) {
|
||||||
// Regular format: month day time/year
|
|
||||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +461,6 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
|
|
||||||
if (name === "." || name === "..") continue;
|
if (name === "." || name === "..") continue;
|
||||||
|
|
||||||
// Parse symbolic link target
|
|
||||||
let actualName = name;
|
let actualName = name;
|
||||||
let linkTarget = undefined;
|
let linkTarget = undefined;
|
||||||
if (isLink && name.includes(" -> ")) {
|
if (isLink && name.includes(" -> ")) {
|
||||||
@@ -462,17 +472,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
files.push({
|
files.push({
|
||||||
name: actualName,
|
name: actualName,
|
||||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||||
size: isDirectory ? undefined : size, // Directories don't show size
|
size: isDirectory ? undefined : size,
|
||||||
modified: dateStr,
|
modified: dateStr,
|
||||||
permissions,
|
permissions,
|
||||||
owner,
|
owner,
|
||||||
group,
|
group,
|
||||||
linkTarget, // Symbolic link target
|
linkTarget,
|
||||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // Add full path
|
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||||
executable:
|
executable:
|
||||||
!isDirectory && !isLink
|
!isDirectory && !isLink
|
||||||
? isExecutableFile(permissions, actualName)
|
? isExecutableFile(permissions, actualName)
|
||||||
: false, // Detect executable files
|
: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,11 +578,9 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
// Support large file reading - increased limit for better compatibility
|
const MAX_READ_SIZE = 500 * 1024 * 1024;
|
||||||
const MAX_READ_SIZE = 500 * 1024 * 1024; // 500MB - much more reasonable limit
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
// Get file size first
|
|
||||||
sshConn.client.exec(
|
sshConn.client.exec(
|
||||||
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
|
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
|
||||||
(sizeErr, sizeStream) => {
|
(sizeErr, sizeStream) => {
|
||||||
@@ -594,19 +602,17 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
|
|
||||||
sizeStream.on("close", (sizeCode) => {
|
sizeStream.on("close", (sizeCode) => {
|
||||||
if (sizeCode !== 0) {
|
if (sizeCode !== 0) {
|
||||||
// Check if it's a file not found error (case-insensitive)
|
|
||||||
const errorLower = sizeErrorData.toLowerCase();
|
const errorLower = sizeErrorData.toLowerCase();
|
||||||
const isFileNotFound = errorLower.includes("no such file or directory") ||
|
const isFileNotFound =
|
||||||
|
errorLower.includes("no such file or directory") ||
|
||||||
errorLower.includes("cannot access") ||
|
errorLower.includes("cannot access") ||
|
||||||
errorLower.includes("not found") ||
|
errorLower.includes("not found") ||
|
||||||
errorLower.includes("resource not found");
|
errorLower.includes("resource not found");
|
||||||
|
|
||||||
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
||||||
return res
|
return res.status(isFileNotFound ? 404 : 500).json({
|
||||||
.status(isFileNotFound ? 404 : 500)
|
|
||||||
.json({
|
|
||||||
error: `Cannot check file size: ${sizeErrorData}`,
|
error: `Cannot check file size: ${sizeErrorData}`,
|
||||||
fileNotFound: isFileNotFound
|
fileNotFound: isFileNotFound,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +623,6 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
return res.status(500).json({ error: "Cannot determine file size" });
|
return res.status(500).json({ error: "Cannot determine file size" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is too large
|
|
||||||
if (fileSize > MAX_READ_SIZE) {
|
if (fileSize > MAX_READ_SIZE) {
|
||||||
fileLogger.warn("File too large for reading", {
|
fileLogger.warn("File too large for reading", {
|
||||||
operation: "file_read",
|
operation: "file_read",
|
||||||
@@ -634,7 +639,6 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// File size is acceptable, proceed with reading
|
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("SSH readFile error:", err);
|
fileLogger.error("SSH readFile error:", err);
|
||||||
@@ -658,17 +662,14 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
|||||||
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if it's a "file not found" error
|
|
||||||
const isFileNotFound =
|
const isFileNotFound =
|
||||||
errorData.includes("No such file or directory") ||
|
errorData.includes("No such file or directory") ||
|
||||||
errorData.includes("cannot access") ||
|
errorData.includes("cannot access") ||
|
||||||
errorData.includes("not found");
|
errorData.includes("not found");
|
||||||
|
|
||||||
return res
|
return res.status(isFileNotFound ? 404 : 500).json({
|
||||||
.status(isFileNotFound ? 404 : 500)
|
|
||||||
.json({
|
|
||||||
error: `Command failed: ${errorData}`,
|
error: `Command failed: ${errorData}`,
|
||||||
fileNotFound: isFileNotFound
|
fileNotFound: isFileNotFound,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,21 +893,12 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
|||||||
.json({ error: "File path, name, and content are required" });
|
.json({ error: "File path, name, and content are required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last active time and extend keepalive for large file operations
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
// For large files, extend the keepalive interval to prevent connection drops
|
const contentSize =
|
||||||
const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length;
|
typeof content === "string"
|
||||||
if (contentSize > 10 * 1024 * 1024) { // 10MB threshold
|
? Buffer.byteLength(content, "utf8")
|
||||||
fileLogger.info("Large file upload detected, extending SSH keepalive", {
|
: content.length;
|
||||||
operation: "file_upload",
|
|
||||||
sessionId,
|
|
||||||
fileName,
|
|
||||||
fileSize: contentSize,
|
|
||||||
});
|
|
||||||
// Note: SSH2 handles keepalive through connection options (keepaliveInterval, keepaliveCountMax)
|
|
||||||
// which are set during connection establishment. No runtime method is available.
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith("/")
|
const fullPath = filePath.endsWith("/")
|
||||||
? filePath + fileName
|
? filePath + fileName
|
||||||
@@ -958,7 +950,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
|||||||
fileName,
|
fileName,
|
||||||
fileSize: contentSize,
|
fileSize: contentSize,
|
||||||
error: streamErr.message,
|
error: streamErr.message,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
});
|
});
|
||||||
@@ -1591,7 +1583,6 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// New API for moving files/folders across directories (for cut operation)
|
|
||||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||||
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -1617,7 +1608,6 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
|||||||
|
|
||||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
// Add timeout for move operation
|
|
||||||
const commandTimeout = setTimeout(() => {
|
const commandTimeout = setTimeout(() => {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(408).json({
|
res.status(408).json({
|
||||||
@@ -1628,7 +1618,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 60000); // 60 second timeout for move operations
|
}, 60000);
|
||||||
|
|
||||||
sshConn.client.exec(moveCommand, (err, stream) => {
|
sshConn.client.exec(moveCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -1745,14 +1735,12 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
|||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
// Use SFTP to read file for binary safety
|
|
||||||
sshConn.client.sftp((err, sftp) => {
|
sshConn.client.sftp((err, sftp) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("SFTP connection failed for download:", err);
|
fileLogger.error("SFTP connection failed for download:", err);
|
||||||
return res.status(500).json({ error: "SFTP connection failed" });
|
return res.status(500).json({ error: "SFTP connection failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file stats first to check if it's a regular file and get size
|
|
||||||
sftp.stat(filePath, (statErr, stats) => {
|
sftp.stat(filePath, (statErr, stats) => {
|
||||||
if (statErr) {
|
if (statErr) {
|
||||||
fileLogger.error("File stat failed for download:", statErr);
|
fileLogger.error("File stat failed for download:", statErr);
|
||||||
@@ -1774,8 +1762,7 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
|||||||
.json({ error: "Cannot download directories or special files" });
|
.json({ error: "Cannot download directories or special files" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support large file downloads - increased limit for better compatibility
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - reasonable for SSH file operations
|
|
||||||
if (stats.size > MAX_FILE_SIZE) {
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
fileLogger.warn("File too large for download", {
|
fileLogger.warn("File too large for download", {
|
||||||
operation: "file_download",
|
operation: "file_download",
|
||||||
@@ -1789,7 +1776,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file content
|
|
||||||
sftp.readFile(filePath, (readErr, data) => {
|
sftp.readFile(filePath, (readErr, data) => {
|
||||||
if (readErr) {
|
if (readErr) {
|
||||||
fileLogger.error("File read failed for download:", readErr);
|
fileLogger.error("File read failed for download:", readErr);
|
||||||
@@ -1798,7 +1784,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
|||||||
.json({ error: `Failed to read file: ${readErr.message}` });
|
.json({ error: `Failed to read file: ${readErr.message}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to base64 for safe transport
|
|
||||||
const base64Content = data.toString("base64");
|
const base64Content = data.toString("base64");
|
||||||
const fileName = filePath.split("/").pop() || "download";
|
const fileName = filePath.split("/").pop() || "download";
|
||||||
|
|
||||||
@@ -1824,7 +1809,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy SSH file/directory
|
|
||||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||||
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
||||||
|
|
||||||
@@ -1842,41 +1826,17 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
|||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract source name
|
|
||||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||||
|
|
||||||
// Linus principle: simplify - generate unique name directly without complex checks
|
|
||||||
const timestamp = Date.now().toString().slice(-8);
|
const timestamp = Date.now().toString().slice(-8);
|
||||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||||
const targetPath = `${targetDir}/${uniqueName}`;
|
const targetPath = `${targetDir}/${uniqueName}`;
|
||||||
|
|
||||||
fileLogger.info("Starting copy operation", {
|
|
||||||
originalName: sourceName,
|
|
||||||
uniqueName,
|
|
||||||
sourcePath,
|
|
||||||
targetPath,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape paths for shell commands
|
|
||||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
// 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"`;
|
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||||
|
|
||||||
fileLogger.info("Starting file copy operation", {
|
|
||||||
operation: "file_copy_start",
|
|
||||||
sessionId,
|
|
||||||
sourcePath,
|
|
||||||
targetPath,
|
|
||||||
uniqueName,
|
|
||||||
command: copyCommand.substring(0, 200) + "...", // Log truncated command
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add timeout to prevent hanging
|
|
||||||
const commandTimeout = setTimeout(() => {
|
const commandTimeout = setTimeout(() => {
|
||||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
@@ -1888,12 +1848,11 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
|||||||
error: "Copy operation timed out",
|
error: "Copy operation timed out",
|
||||||
toast: {
|
toast: {
|
||||||
type: "error",
|
type: "error",
|
||||||
message:
|
message: "Copy operation timed out. SSH connection may be unstable.",
|
||||||
"Copy operation timed out. SSH connection may be unstable.",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 60000); // 60 second timeout for large files
|
}, 60000);
|
||||||
|
|
||||||
sshConn.client.exec(copyCommand, (err, stream) => {
|
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -1908,30 +1867,16 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
|||||||
let errorData = "";
|
let errorData = "";
|
||||||
let stdoutData = "";
|
let stdoutData = "";
|
||||||
|
|
||||||
// Monitor both stdout and stderr
|
|
||||||
stream.on("data", (data: Buffer) => {
|
stream.on("data", (data: Buffer) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
stdoutData += output;
|
stdoutData += output;
|
||||||
fileLogger.info("Copy command stdout", {
|
|
||||||
output: output.substring(0, 200),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on("data", (data: Buffer) => {
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
errorData += output;
|
errorData += output;
|
||||||
fileLogger.info("Copy command stderr", {
|
|
||||||
output: output.substring(0, 200),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
fileLogger.info("Copy command completed", {
|
|
||||||
code,
|
|
||||||
errorData,
|
|
||||||
hasError: errorData.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const fullErrorInfo =
|
const fullErrorInfo =
|
||||||
@@ -1965,8 +1910,8 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify copy completion with COPY_SUCCESS marker or exit code 0
|
const copySuccessful =
|
||||||
const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0;
|
stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||||
|
|
||||||
if (copySuccessful) {
|
if (copySuccessful) {
|
||||||
fileLogger.success("Item copied successfully", {
|
fileLogger.success("Item copied successfully", {
|
||||||
@@ -2024,33 +1969,8 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
fileLogger.error("Copy operation error:", error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to determine MIME type based on file extension
|
|
||||||
function getMimeType(fileName: string): string {
|
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
txt: "text/plain",
|
|
||||||
json: "application/json",
|
|
||||||
js: "text/javascript",
|
|
||||||
html: "text/html",
|
|
||||||
css: "text/css",
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
pdf: "application/pdf",
|
|
||||||
zip: "application/zip",
|
|
||||||
tar: "application/x-tar",
|
|
||||||
gz: "application/gzip",
|
|
||||||
};
|
|
||||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
Object.keys(sshSessions).forEach(cleanupSession);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -2061,7 +1981,6 @@ process.on("SIGTERM", () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Execute executable file
|
|
||||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||||
const { sessionId, filePath, hostId, userId } = req.body;
|
const { sessionId, filePath, hostId, userId } = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -2085,7 +2004,6 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
// Check if file exists and is executable
|
|
||||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||||
|
|
||||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||||
@@ -2106,16 +2024,8 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "File is not executable" });
|
return res.status(400).json({ error: "File is not executable" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute file
|
|
||||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||||
|
|
||||||
fileLogger.info("Executing file", {
|
|
||||||
operation: "execute_file",
|
|
||||||
sessionId,
|
|
||||||
filePath,
|
|
||||||
command: executeCommand.substring(0, 100) + "...",
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.client.exec(executeCommand, (err, stream) => {
|
sshConn.client.exec(executeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("SSH executeFile error:", err);
|
fileLogger.error("SSH executeFile error:", err);
|
||||||
@@ -2134,7 +2044,6 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
// Extract exit code from output
|
|
||||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||||
const actualExitCode = exitCodeMatch
|
const actualExitCode = exitCodeMatch
|
||||||
? parseInt(exitCodeMatch[1])
|
? parseInt(exitCodeMatch[1])
|
||||||
@@ -2172,20 +2081,12 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
|
|
||||||
const PORT = 30004;
|
const PORT = 30004;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
fileLogger.success("File Manager API server started", {
|
|
||||||
operation: "server_start",
|
|
||||||
port: PORT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize AuthManager for JWT verification
|
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
fileLogger.info("AuthManager initialized for file manager", {
|
|
||||||
operation: "auth_init",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fileLogger.error("Failed to initialize AuthManager", err, {
|
fileLogger.error("Failed to initialize AuthManager", err, {
|
||||||
operation: "auth_init_error",
|
operation: "auth_init_error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ app.use(
|
|||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Allow any HTTPS origin (production deployments)
|
// Allow any HTTPS origin (production deployments)
|
||||||
@@ -327,7 +327,9 @@ app.use(authManager.createAuthMiddleware());
|
|||||||
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
|
async function fetchAllHosts(
|
||||||
|
userId: string,
|
||||||
|
): Promise<SSHHostWithCredentials[]> {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hosts = await SimpleDBOps.select(
|
||||||
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
|
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
|
||||||
@@ -366,13 +368,16 @@ async function fetchHostById(
|
|||||||
statsLogger.debug("User data locked - cannot fetch host", {
|
statsLogger.debug("User data locked - cannot fetch host", {
|
||||||
operation: "fetchHostById_data_locked",
|
operation: "fetchHostById_data_locked",
|
||||||
userId,
|
userId,
|
||||||
hostId: id
|
hostId: id,
|
||||||
});
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts = await SimpleDBOps.select(
|
const hosts = await SimpleDBOps.select(
|
||||||
getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -512,7 +517,14 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
"3des-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"],
|
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"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
} as ConnectConfig;
|
} as ConnectConfig;
|
||||||
@@ -879,7 +891,7 @@ app.get("/status", async (req, res) => {
|
|||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED"
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +913,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED"
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,7 +945,7 @@ app.post("/refresh", async (req, res) => {
|
|||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED"
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +961,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED"
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,38 +1008,22 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
statsLogger.info("Received SIGINT, shutting down gracefully");
|
|
||||||
connectionPool.destroy();
|
connectionPool.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
statsLogger.info("Received SIGTERM, shutting down gracefully");
|
|
||||||
connectionPool.destroy();
|
connectionPool.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 30005;
|
const PORT = 30005;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
statsLogger.success("Server Stats API server started", {
|
|
||||||
operation: "server_start",
|
|
||||||
port: PORT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize AuthManager for JWT verification
|
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
statsLogger.info("AuthManager initialized for metrics collection", {
|
|
||||||
operation: "auth_init",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statsLogger.error("Failed to initialize AuthManager", err, {
|
statsLogger.error("Failed to initialize AuthManager", err, {
|
||||||
operation: "auth_init_error",
|
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",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+52
-66
@@ -9,16 +9,13 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
|||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { UserCrypto } from "../utils/user-crypto.js";
|
import { UserCrypto } from "../utils/user-crypto.js";
|
||||||
|
|
||||||
// Get auth instances
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const userCrypto = UserCrypto.getInstance();
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
|
||||||
// Track user connections for rate limiting
|
|
||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
port: 30002,
|
port: 30002,
|
||||||
// WebSocket authentication during handshake
|
|
||||||
verifyClient: async (info) => {
|
verifyClient: async (info) => {
|
||||||
try {
|
try {
|
||||||
const url = parseUrl(info.req.url!, true);
|
const url = parseUrl(info.req.url!, true);
|
||||||
@@ -28,7 +25,7 @@ const wss = new WebSocketServer({
|
|||||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||||
operation: "websocket_auth_reject",
|
operation: "websocket_auth_reject",
|
||||||
reason: "missing_token",
|
reason: "missing_token",
|
||||||
ip: info.req.socket.remoteAddress
|
ip: info.req.socket.remoteAddress,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -39,23 +36,24 @@ const wss = new WebSocketServer({
|
|||||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||||
operation: "websocket_auth_reject",
|
operation: "websocket_auth_reject",
|
||||||
reason: "invalid_token",
|
reason: "invalid_token",
|
||||||
ip: info.req.socket.remoteAddress
|
ip: info.req.socket.remoteAddress,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for TOTP pending (should not allow terminal access during TOTP)
|
|
||||||
if (payload.pendingTOTP) {
|
if (payload.pendingTOTP) {
|
||||||
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
|
sshLogger.warn(
|
||||||
|
"WebSocket connection rejected: TOTP verification pending",
|
||||||
|
{
|
||||||
operation: "websocket_auth_reject",
|
operation: "websocket_auth_reject",
|
||||||
reason: "totp_pending",
|
reason: "totp_pending",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
ip: info.req.socket.remoteAddress
|
ip: info.req.socket.remoteAddress,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connection limits per user (max 3 concurrent connections)
|
|
||||||
const existingConnections = userConnections.get(payload.userId);
|
const existingConnections = userConnections.get(payload.userId);
|
||||||
if (existingConnections && existingConnections.size >= 3) {
|
if (existingConnections && existingConnections.size >= 3) {
|
||||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||||
@@ -63,39 +61,29 @@ const wss = new WebSocketServer({
|
|||||||
reason: "connection_limit",
|
reason: "connection_limit",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
currentConnections: existingConnections.size,
|
currentConnections: existingConnections.size,
|
||||||
ip: info.req.socket.remoteAddress
|
ip: info.req.socket.remoteAddress,
|
||||||
});
|
});
|
||||||
return false;
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("WebSocket authentication error", error, {
|
sshLogger.error("WebSocket authentication error", error, {
|
||||||
operation: "websocket_auth_error",
|
operation: "websocket_auth_error",
|
||||||
ip: info.req.socket.remoteAddress
|
ip: info.req.socket.remoteAddress,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
port: 30002,
|
port: 30002,
|
||||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
features: ["JWT_auth", "connection_limits", "data_access_control"],
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on("connection", async (ws: WebSocket, req) => {
|
wss.on("connection", async (ws: WebSocket, req) => {
|
||||||
// Linus principle: eliminate complexity - always parse JWT from URL directly
|
|
||||||
let userId: string | undefined;
|
let userId: string | undefined;
|
||||||
let userPayload: any;
|
let userPayload: any;
|
||||||
|
|
||||||
@@ -104,75 +92,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const token = url.query.token as string;
|
const token = url.query.token as string;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sshLogger.warn("WebSocket connection rejected: missing token in connection", {
|
sshLogger.warn(
|
||||||
|
"WebSocket connection rejected: missing token in connection",
|
||||||
|
{
|
||||||
operation: "websocket_connection_reject",
|
operation: "websocket_connection_reject",
|
||||||
reason: "missing_token",
|
reason: "missing_token",
|
||||||
ip: req.socket.remoteAddress
|
ip: req.socket.remoteAddress,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
sshLogger.warn("WebSocket connection rejected: invalid token in connection", {
|
sshLogger.warn(
|
||||||
|
"WebSocket connection rejected: invalid token in connection",
|
||||||
|
{
|
||||||
operation: "websocket_connection_reject",
|
operation: "websocket_connection_reject",
|
||||||
reason: "invalid_token",
|
reason: "invalid_token",
|
||||||
ip: req.socket.remoteAddress
|
ip: req.socket.remoteAddress,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
userId = payload.userId;
|
userId = payload.userId;
|
||||||
userPayload = payload;
|
userPayload = payload;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("WebSocket JWT verification failed during connection", error, {
|
sshLogger.error(
|
||||||
|
"WebSocket JWT verification failed during connection",
|
||||||
|
error,
|
||||||
|
{
|
||||||
operation: "websocket_connection_auth_error",
|
operation: "websocket_connection_auth_error",
|
||||||
ip: req.socket.remoteAddress
|
ip: req.socket.remoteAddress,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
ws.close(1008, "Authentication required");
|
ws.close(1008, "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check data access permissions
|
|
||||||
const dataKey = userCrypto.getUserDataKey(userId);
|
const dataKey = userCrypto.getUserDataKey(userId);
|
||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||||
operation: "websocket_data_locked",
|
operation: "websocket_data_locked",
|
||||||
userId,
|
userId,
|
||||||
ip: req.socket.remoteAddress
|
ip: req.socket.remoteAddress,
|
||||||
});
|
});
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Data locked - re-authenticate with password",
|
message: "Data locked - re-authenticate with password",
|
||||||
code: "DATA_LOCKED"
|
code: "DATA_LOCKED",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
ws.close(1008, "Data access required");
|
ws.close(1008, "Data access required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track user connections for limits
|
|
||||||
if (!userConnections.has(userId)) {
|
if (!userConnections.has(userId)) {
|
||||||
userConnections.set(userId, new Set());
|
userConnections.set(userId, new Set());
|
||||||
}
|
}
|
||||||
const userWs = userConnections.get(userId)!;
|
const userWs = userConnections.get(userId)!;
|
||||||
userWs.add(ws);
|
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 sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
// Clean up user connection tracking
|
|
||||||
const userWs = userConnections.get(userId);
|
const userWs = userConnections.get(userId);
|
||||||
if (userWs) {
|
if (userWs) {
|
||||||
userWs.delete(ws);
|
userWs.delete(ws);
|
||||||
@@ -181,29 +170,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sshLogger.info("WebSocket connection closed", {
|
|
||||||
operation: "websocket_connection_closed",
|
|
||||||
userId,
|
|
||||||
remainingConnections: userWs?.size || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (msg: RawData) => {
|
ws.on("message", (msg: RawData) => {
|
||||||
// Verify user still has data access before processing any messages
|
|
||||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||||
if (!currentDataKey) {
|
if (!currentDataKey) {
|
||||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||||
operation: "websocket_message_rejected",
|
operation: "websocket_message_rejected",
|
||||||
userId,
|
userId,
|
||||||
reason: "data_access_expired"
|
reason: "data_access_expired",
|
||||||
});
|
});
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Data access expired - please re-authenticate",
|
message: "Data access expired - please re-authenticate",
|
||||||
code: "DATA_EXPIRED"
|
code: "DATA_EXPIRED",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
ws.close(1008, "Data access expired");
|
ws.close(1008, "Data access expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -225,7 +209,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "connectToHost":
|
case "connectToHost":
|
||||||
// Ensure userId is attached to hostConfig for secure credential resolution
|
|
||||||
if (data.hostConfig) {
|
if (data.hostConfig) {
|
||||||
data.hostConfig.userId = userId;
|
data.hostConfig.userId = userId;
|
||||||
}
|
}
|
||||||
@@ -390,7 +373,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.privateKey || credential.key, // prefer new privateKey field
|
key: credential.privateKey || credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
@@ -480,16 +463,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
setupPingInterval();
|
setupPingInterval();
|
||||||
|
|
||||||
// Change to initial path if specified
|
|
||||||
if (initialPath && initialPath.trim() !== "") {
|
if (initialPath && initialPath.trim() !== "") {
|
||||||
// Send cd command to change directory
|
|
||||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||||
stream.write(cdCommand);
|
stream.write(cdCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute command if specified
|
|
||||||
if (executeCommand && executeCommand.trim() !== "") {
|
if (executeCommand && executeCommand.trim() !== "") {
|
||||||
// Wait a moment for the cd command to complete, then execute the command
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const command = `${executeCommand}\n`;
|
const command = `${executeCommand}\n`;
|
||||||
stream.write(command);
|
stream.write(command);
|
||||||
@@ -604,7 +583,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
"3des-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"],
|
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"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+106
-115
@@ -22,33 +22,27 @@ const app = express();
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
// Allow requests with no origin (like mobile apps or curl requests)
|
|
||||||
if (!origin) return callback(null, true);
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
// Allow localhost and 127.0.0.1 for development
|
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Allow any HTTPS origin (production deployments)
|
|
||||||
if (origin.startsWith("https://")) {
|
if (origin.startsWith("https://")) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow any HTTP origin for self-hosted scenarios
|
|
||||||
if (origin.startsWith("http://")) {
|
if (origin.startsWith("http://")) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check against allowed development origins
|
|
||||||
if (allowedOrigins.includes(origin)) {
|
if (allowedOrigins.includes(origin)) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject other origins
|
|
||||||
callback(new Error("Not allowed by CORS"));
|
callback(new Error("Not allowed by CORS"));
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -158,18 +152,15 @@ function getTunnelMarker(tunnelName: string) {
|
|||||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void {
|
function cleanupTunnelResources(
|
||||||
tunnelLogger.info(`Cleaning up resources for tunnel '${tunnelName}' (force=${forceCleanup})`);
|
tunnelName: string,
|
||||||
|
forceCleanup = false,
|
||||||
// Prevent concurrent cleanup operations
|
): void {
|
||||||
if (cleanupInProgress.has(tunnelName)) {
|
if (cleanupInProgress.has(tunnelName)) {
|
||||||
tunnelLogger.info(`Cleanup already in progress for '${tunnelName}', skipping`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protect connecting tunnels unless forced
|
|
||||||
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
|
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
|
||||||
tunnelLogger.info(`Tunnel '${tunnelName}' is connecting, skipping cleanup (use force=true to override)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +174,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
|||||||
tunnelLogger.error(
|
tunnelLogger.error(
|
||||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
tunnelLogger.info(`Successfully cleaned up remote tunnel processes for '${tunnelName}'`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -210,7 +199,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
|||||||
try {
|
try {
|
||||||
const conn = activeTunnels.get(tunnelName);
|
const conn = activeTunnels.get(tunnelName);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
|
|
||||||
conn.end();
|
conn.end();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -220,7 +208,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
if (tunnelVerifications.has(tunnelName)) {
|
||||||
@@ -454,10 +441,8 @@ async function connectSSHTunnel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark tunnel as connecting to protect from cleanup
|
|
||||||
tunnelConnecting.add(tunnelName);
|
tunnelConnecting.add(tunnelName);
|
||||||
|
|
||||||
// Force cleanup any existing resources before new connection
|
|
||||||
cleanupTunnelResources(tunnelName, true);
|
cleanupTunnelResources(tunnelName, true);
|
||||||
|
|
||||||
if (retryAttempt === 0) {
|
if (retryAttempt === 0) {
|
||||||
@@ -519,7 +504,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedSourceCredentials = {
|
resolvedSourceCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
sshKey: credential.privateKey || credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
@@ -549,11 +534,10 @@ async function connectSSHTunnel(
|
|||||||
authMethod: tunnelConfig.endpointAuthMethod,
|
authMethod: tunnelConfig.endpointAuthMethod,
|
||||||
};
|
};
|
||||||
|
|
||||||
tunnelLogger.info(`Source credentials for '${tunnelName}': authMethod=${resolvedSourceCredentials.authMethod}, hasPassword=${!!resolvedSourceCredentials.password}, hasSSHKey=${!!resolvedSourceCredentials.sshKey}`);
|
if (
|
||||||
tunnelLogger.info(`Final endpoint credentials for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}, credentialId=${tunnelConfig.endpointCredentialId}`);
|
resolvedEndpointCredentials.authMethod === "password" &&
|
||||||
|
!resolvedEndpointCredentials.password
|
||||||
// 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.`;
|
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);
|
tunnelLogger.error(errorMessage);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -564,7 +548,10 @@ async function connectSSHTunnel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey) {
|
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.`;
|
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);
|
tunnelLogger.error(errorMessage);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -591,12 +578,11 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedEndpointCredentials = {
|
resolvedEndpointCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
sshKey: credential.privateKey || credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
};
|
};
|
||||||
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
|
|
||||||
} else {
|
} else {
|
||||||
tunnelLogger.warn("No endpoint credentials found in database", {
|
tunnelLogger.warn("No endpoint credentials found in database", {
|
||||||
operation: "tunnel_connect",
|
operation: "tunnel_connect",
|
||||||
@@ -646,7 +632,6 @@ async function connectSSHTunnel(
|
|||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||||
|
|
||||||
// Clear connecting state on error
|
|
||||||
tunnelConnecting.delete(tunnelName);
|
tunnelConnecting.delete(tunnelName);
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
if (activeRetryTimers.has(tunnelName)) {
|
||||||
@@ -677,7 +662,6 @@ async function connectSSHTunnel(
|
|||||||
conn.on("close", () => {
|
conn.on("close", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
// Clear connecting state on close
|
|
||||||
tunnelConnecting.delete(tunnelName);
|
tunnelConnecting.delete(tunnelName);
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
if (activeRetryTimers.has(tunnelName)) {
|
||||||
@@ -722,8 +706,6 @@ async function connectSSHTunnel(
|
|||||||
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}`;
|
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) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
tunnelLogger.error(
|
tunnelLogger.error(
|
||||||
@@ -750,7 +732,6 @@ async function connectSSHTunnel(
|
|||||||
!manualDisconnects.has(tunnelName) &&
|
!manualDisconnects.has(tunnelName) &&
|
||||||
activeTunnels.has(tunnelName)
|
activeTunnels.has(tunnelName)
|
||||||
) {
|
) {
|
||||||
// Clear connecting state on successful connection
|
|
||||||
tunnelConnecting.delete(tunnelName);
|
tunnelConnecting.delete(tunnelName);
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -827,7 +808,6 @@ async function connectSSHTunnel(
|
|||||||
stream.stdout?.on("data", (data: Buffer) => {
|
stream.stdout?.on("data", (data: Buffer) => {
|
||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
if (output) {
|
if (output) {
|
||||||
tunnelLogger.info(`SSH stdout for '${tunnelName}': ${output}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -836,25 +816,42 @@ async function connectSSHTunnel(
|
|||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString().trim();
|
const errorMsg = data.toString().trim();
|
||||||
if (errorMsg) {
|
if (errorMsg) {
|
||||||
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
|
const isDebugMessage =
|
||||||
|
errorMsg.startsWith("debug1:") ||
|
||||||
|
errorMsg.startsWith("debug2:") ||
|
||||||
|
errorMsg.startsWith("debug3:") ||
|
||||||
|
errorMsg.includes("Reading configuration data") ||
|
||||||
|
errorMsg.includes("include /etc/ssh/ssh_config.d") ||
|
||||||
|
errorMsg.includes("matched no files") ||
|
||||||
|
errorMsg.includes("Applying options for");
|
||||||
|
|
||||||
// Check for specific SSH errors
|
if (!isDebugMessage) {
|
||||||
if (errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found")) {
|
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMsg.includes("sshpass: command not found") ||
|
||||||
|
errorMsg.includes("sshpass not found")
|
||||||
|
) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
|
reason:
|
||||||
|
"sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for port forwarding errors
|
if (
|
||||||
if (errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed")) {
|
errorMsg.includes("remote port forwarding failed") ||
|
||||||
|
errorMsg.includes("Error: remote port forwarding failed")
|
||||||
|
) {
|
||||||
const portMatch = errorMsg.match(/listen port (\d+)/);
|
const portMatch = errorMsg.match(/listen port (\d+)/);
|
||||||
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
|
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
|
||||||
|
|
||||||
tunnelLogger.error(`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`);
|
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)) {
|
if (activeTunnels.has(tunnelName)) {
|
||||||
const conn = activeTunnels.get(tunnelName);
|
const conn = activeTunnels.get(tunnelName);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
@@ -905,7 +902,14 @@ async function connectSSHTunnel(
|
|||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
"3des-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"],
|
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"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -975,9 +979,7 @@ async function killRemoteTunnelByMarker(
|
|||||||
callback: (err?: Error) => void,
|
callback: (err?: Error) => void,
|
||||||
) {
|
) {
|
||||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
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 = {
|
let resolvedSourceCredentials = {
|
||||||
password: tunnelConfig.sourcePassword,
|
password: tunnelConfig.sourcePassword,
|
||||||
sshKey: tunnelConfig.sourceSSHKey,
|
sshKey: tunnelConfig.sourceSSHKey,
|
||||||
@@ -1049,7 +1051,14 @@ async function killRemoteTunnelByMarker(
|
|||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
"3des-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"],
|
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"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1085,7 +1094,6 @@ async function killRemoteTunnelByMarker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn.on("ready", () => {
|
conn.on("ready", () => {
|
||||||
// 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`;
|
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) => {
|
conn.exec(checkCmd, (err, stream) => {
|
||||||
@@ -1095,31 +1103,27 @@ async function killRemoteTunnelByMarker(
|
|||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
if (output) {
|
if (output) {
|
||||||
foundProcesses = true;
|
foundProcesses = true;
|
||||||
tunnelLogger.info(`Found running tunnel processes for '${tunnelName}': ${output}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", () => {
|
stream.on("close", () => {
|
||||||
if (!foundProcesses) {
|
if (!foundProcesses) {
|
||||||
tunnelLogger.info(`No running tunnel processes found for '${tunnelName}', cleanup not needed`);
|
|
||||||
conn.end();
|
conn.end();
|
||||||
callback();
|
callback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute kill commands sequentially for better control
|
|
||||||
const killCmds = [
|
const killCmds = [
|
||||||
`pkill -TERM -f '${tunnelMarker}'`,
|
`pkill -TERM -f '${tunnelMarker}'`,
|
||||||
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
|
`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 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
|
||||||
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, // Force kill after delay
|
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
|
||||||
];
|
];
|
||||||
|
|
||||||
let commandIndex = 0;
|
let commandIndex = 0;
|
||||||
|
|
||||||
function executeNextKillCommand() {
|
function executeNextKillCommand() {
|
||||||
if (commandIndex >= killCmds.length) {
|
if (commandIndex >= killCmds.length) {
|
||||||
// Final verification
|
|
||||||
conn.exec(checkCmd, (err, verifyStream) => {
|
conn.exec(checkCmd, (err, verifyStream) => {
|
||||||
let stillRunning = false;
|
let stillRunning = false;
|
||||||
|
|
||||||
@@ -1127,15 +1131,17 @@ async function killRemoteTunnelByMarker(
|
|||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
if (output) {
|
if (output) {
|
||||||
stillRunning = true;
|
stillRunning = true;
|
||||||
tunnelLogger.warn(`Processes still running after cleanup for '${tunnelName}': ${output}`);
|
tunnelLogger.warn(
|
||||||
|
`Processes still running after cleanup for '${tunnelName}': ${output}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
verifyStream.on("close", () => {
|
verifyStream.on("close", () => {
|
||||||
if (!stillRunning) {
|
if (stillRunning) {
|
||||||
tunnelLogger.info(`All tunnel processes successfully terminated for '${tunnelName}'`);
|
tunnelLogger.warn(
|
||||||
} else {
|
`Some tunnel processes may still be running for '${tunnelName}'`,
|
||||||
tunnelLogger.warn(`Some tunnel processes may still be running for '${tunnelName}'`);
|
);
|
||||||
}
|
}
|
||||||
conn.end();
|
conn.end();
|
||||||
callback();
|
callback();
|
||||||
@@ -1148,13 +1154,13 @@ async function killRemoteTunnelByMarker(
|
|||||||
|
|
||||||
conn.exec(killCmd, (err, stream) => {
|
conn.exec(killCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`);
|
tunnelLogger.warn(
|
||||||
|
`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tunnelLogger.info(`Executed kill command ${commandIndex + 1} for '${tunnelName}': ${killCmd.replace(/sleep \d+ && /, '')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
tunnelLogger.info(`Kill command ${commandIndex + 1} completed with code ${code} for '${tunnelName}'`);
|
|
||||||
commandIndex++;
|
commandIndex++;
|
||||||
executeNextKillCommand();
|
executeNextKillCommand();
|
||||||
});
|
});
|
||||||
@@ -1162,14 +1168,15 @@ async function killRemoteTunnelByMarker(
|
|||||||
stream.on("data", (data) => {
|
stream.on("data", (data) => {
|
||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
if (output) {
|
if (output) {
|
||||||
tunnelLogger.info(`Kill command ${commandIndex + 1} output for '${tunnelName}': ${output}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
if (output && !output.includes("debug1")) {
|
if (output && !output.includes("debug1")) {
|
||||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`);
|
tunnelLogger.warn(
|
||||||
|
`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1181,7 +1188,9 @@ async function killRemoteTunnelByMarker(
|
|||||||
});
|
});
|
||||||
|
|
||||||
conn.on("error", (err) => {
|
conn.on("error", (err) => {
|
||||||
tunnelLogger.error(`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`);
|
tunnelLogger.error(
|
||||||
|
`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`,
|
||||||
|
);
|
||||||
callback(err);
|
callback(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1212,8 +1221,6 @@ app.post("/ssh/tunnel/connect", (req, res) => {
|
|||||||
|
|
||||||
const tunnelName = tunnelConfig.name;
|
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);
|
cleanupTunnelResources(tunnelName);
|
||||||
|
|
||||||
manualDisconnects.delete(tunnelName);
|
manualDisconnects.delete(tunnelName);
|
||||||
@@ -1247,8 +1254,6 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
|
|||||||
activeRetryTimers.delete(tunnelName);
|
activeRetryTimers.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediately clean up active connections (force cleanup)
|
|
||||||
tunnelLogger.info(`Manual disconnect requested for '${tunnelName}', cleaning up resources`);
|
|
||||||
cleanupTunnelResources(tunnelName, true);
|
cleanupTunnelResources(tunnelName, true);
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -1287,8 +1292,6 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
|||||||
countdownIntervals.delete(tunnelName);
|
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);
|
cleanupTunnelResources(tunnelName, true);
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -1309,11 +1312,9 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
|||||||
|
|
||||||
async function initializeAutoStartTunnels(): Promise<void> {
|
async function initializeAutoStartTunnels(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get internal auth token from SystemCrypto
|
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||||
|
|
||||||
// Get autostart hosts for tunnel configs
|
|
||||||
const autostartResponse = await axios.get(
|
const autostartResponse = await axios.get(
|
||||||
"http://localhost:30001/ssh/db/host/internal",
|
"http://localhost:30001/ssh/db/host/internal",
|
||||||
{
|
{
|
||||||
@@ -1324,7 +1325,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all hosts for endpointHost resolution
|
|
||||||
const allHostsResponse = await axios.get(
|
const allHostsResponse = await axios.get(
|
||||||
"http://localhost:30001/ssh/db/host/internal/all",
|
"http://localhost:30001/ssh/db/host/internal/all",
|
||||||
{
|
{
|
||||||
@@ -1339,7 +1339,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||||
const autoStartTunnels: TunnelConfig[] = [];
|
const autoStartTunnels: TunnelConfig[] = [];
|
||||||
|
|
||||||
tunnelLogger.info(`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`);
|
tunnelLogger.info(
|
||||||
|
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const host of autostartHosts) {
|
for (const host of autostartHosts) {
|
||||||
if (host.enableTunnel && host.tunnelConnections) {
|
if (host.enableTunnel && host.tunnelConnections) {
|
||||||
@@ -1352,50 +1354,39 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (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 = {
|
const tunnelConfig: TunnelConfig = {
|
||||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
sourceIP: host.ip,
|
sourceIP: host.ip,
|
||||||
sourceSSHPort: host.port,
|
sourceSSHPort: host.port,
|
||||||
sourceUsername: host.username,
|
sourceUsername: host.username,
|
||||||
// Prefer autostart credentials for source host, fallback to encrypted credentials
|
|
||||||
sourcePassword: host.autostartPassword || host.password,
|
sourcePassword: host.autostartPassword || host.password,
|
||||||
sourceAuthMethod: host.authType,
|
sourceAuthMethod: host.authType,
|
||||||
sourceSSHKey: host.autostartKey || host.key,
|
sourceSSHKey: host.autostartKey || host.key,
|
||||||
sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
|
sourceKeyPassword:
|
||||||
|
host.autostartKeyPassword || host.keyPassword,
|
||||||
sourceKeyType: host.keyType,
|
sourceKeyType: host.keyType,
|
||||||
sourceCredentialId: host.credentialId,
|
sourceCredentialId: host.credentialId,
|
||||||
sourceUserId: host.userId,
|
sourceUserId: host.userId,
|
||||||
endpointIP: endpointHost.ip,
|
endpointIP: endpointHost.ip,
|
||||||
endpointSSHPort: endpointHost.port,
|
endpointSSHPort: endpointHost.port,
|
||||||
endpointUsername: endpointHost.username,
|
endpointUsername: endpointHost.username,
|
||||||
// Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
|
endpointPassword:
|
||||||
endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
|
tunnelConnection.endpointPassword ||
|
||||||
endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
|
endpointHost.autostartPassword ||
|
||||||
endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
|
endpointHost.password,
|
||||||
endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
|
endpointAuthMethod:
|
||||||
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
|
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,
|
endpointCredentialId: endpointHost.credentialId,
|
||||||
endpointUserId: endpointHost.userId,
|
endpointUserId: endpointHost.userId,
|
||||||
sourcePort: tunnelConnection.sourcePort,
|
sourcePort: tunnelConnection.sourcePort,
|
||||||
@@ -1406,24 +1397,30 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
isPinned: host.pin,
|
isPinned: host.pin,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate source and endpoint credentials availability
|
|
||||||
const hasSourcePassword = host.autostartPassword;
|
const hasSourcePassword = host.autostartPassword;
|
||||||
const hasSourceKey = host.autostartKey;
|
const hasSourceKey = host.autostartKey;
|
||||||
const hasEndpointPassword = tunnelConnection.endpointPassword || endpointHost.autostartPassword;
|
const hasEndpointPassword =
|
||||||
const hasEndpointKey = tunnelConnection.endpointKey || endpointHost.autostartKey;
|
tunnelConnection.endpointPassword ||
|
||||||
|
endpointHost.autostartPassword;
|
||||||
|
const hasEndpointKey =
|
||||||
|
tunnelConnection.endpointKey || endpointHost.autostartKey;
|
||||||
|
|
||||||
if (!hasSourcePassword && !hasSourceKey) {
|
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.`);
|
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) {
|
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.`);
|
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);
|
autoStartTunnels.push(tunnelConfig);
|
||||||
} else {
|
} else {
|
||||||
tunnelLogger.error(
|
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(', ')}`,
|
`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(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1431,8 +1428,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
|
||||||
|
|
||||||
for (const tunnelConfig of autoStartTunnels) {
|
for (const tunnelConfig of autoStartTunnels) {
|
||||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||||
|
|
||||||
@@ -1454,10 +1449,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
|
|
||||||
const PORT = 30003;
|
const PORT = 30003;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
tunnelLogger.success("SSH Tunnel API server started", {
|
|
||||||
operation: "server_start",
|
|
||||||
port: PORT,
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initializeAutoStartTunnels();
|
initializeAutoStartTunnels();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
+39
-99
@@ -1,6 +1,3 @@
|
|||||||
// npx tsc -p tsconfig.node.json
|
|
||||||
// node ./dist/backend/starter.js
|
|
||||||
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
@@ -13,33 +10,15 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Load persistent .env file from data directory (where database is stored)
|
|
||||||
// Always try to load from data directory, regardless of NODE_ENV
|
|
||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const envPath = path.join(dataDir, ".env");
|
const envPath = path.join(dataDir, ".env");
|
||||||
try {
|
try {
|
||||||
await fs.access(envPath);
|
await fs.access(envPath);
|
||||||
// Load the persistent .env file and override process.env
|
|
||||||
const persistentConfig = dotenv.config({ path: envPath });
|
const persistentConfig = dotenv.config({ path: envPath });
|
||||||
if (persistentConfig.parsed) {
|
if (persistentConfig.parsed) {
|
||||||
// Override process.env with values from persistent config
|
|
||||||
Object.assign(process.env, persistentConfig.parsed);
|
Object.assign(process.env, persistentConfig.parsed);
|
||||||
}
|
}
|
||||||
systemLogger.info(`Loaded persistent configuration from ${envPath}`, {
|
} catch {}
|
||||||
operation: "config_load",
|
|
||||||
hasDatabaseKey: !!persistentConfig.parsed?.DATABASE_KEY,
|
|
||||||
databaseKeyLength: persistentConfig.parsed?.DATABASE_KEY?.length || 0,
|
|
||||||
hasJwtSecret: !!persistentConfig.parsed?.JWT_SECRET,
|
|
||||||
jwtSecretLength: persistentConfig.parsed?.JWT_SECRET?.length || 0,
|
|
||||||
hasInternalAuthToken: !!persistentConfig.parsed?.INTERNAL_AUTH_TOKEN,
|
|
||||||
internalAuthTokenLength: persistentConfig.parsed?.INTERNAL_AUTH_TOKEN?.length || 0
|
|
||||||
});
|
|
||||||
} 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";
|
const version = process.env.VERSION || "unknown";
|
||||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||||
@@ -47,72 +26,73 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
version: version,
|
version: version,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize system crypto keys FIRST (after .env is loaded)
|
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
await systemCrypto.initializeJWTSecret();
|
await systemCrypto.initializeJWTSecret();
|
||||||
await systemCrypto.initializeDatabaseKey();
|
await systemCrypto.initializeDatabaseKey();
|
||||||
await systemCrypto.initializeInternalAuthToken();
|
await systemCrypto.initializeInternalAuthToken();
|
||||||
|
|
||||||
// Auto-initialize SSL/TLS configuration
|
|
||||||
await AutoSSLSetup.initialize();
|
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");
|
const dbModule = await import("./database/db/index.js");
|
||||||
await dbModule.initializeDatabase();
|
await dbModule.initializeDatabase();
|
||||||
systemLogger.success("Database initialized successfully", {
|
if (process.env.NODE_ENV === "production") {
|
||||||
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[] = [];
|
const securityIssues: string[] = [];
|
||||||
|
|
||||||
// Check JWT and database keys (auto-generated if missing - warnings only)
|
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
systemLogger.warn("JWT_SECRET not set - using auto-generated keys (consider setting for production)", {
|
systemLogger.warn(
|
||||||
|
"JWT_SECRET not set - using auto-generated keys (consider setting for production)",
|
||||||
|
{
|
||||||
operation: "security_warning",
|
operation: "security_warning",
|
||||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
note: "Auto-generated keys are secure but not persistent across deployments",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else if (process.env.JWT_SECRET.length < 64) {
|
} else if (process.env.JWT_SECRET.length < 64) {
|
||||||
securityIssues.push("JWT_SECRET should be at least 64 characters in production");
|
securityIssues.push(
|
||||||
|
"JWT_SECRET should be at least 64 characters in production",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.DATABASE_KEY) {
|
if (!process.env.DATABASE_KEY) {
|
||||||
systemLogger.warn("DATABASE_KEY not set - using auto-generated keys (consider setting for production)", {
|
systemLogger.warn(
|
||||||
|
"DATABASE_KEY not set - using auto-generated keys (consider setting for production)",
|
||||||
|
{
|
||||||
operation: "security_warning",
|
operation: "security_warning",
|
||||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
note: "Auto-generated keys are secure but not persistent across deployments",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else if (process.env.DATABASE_KEY.length < 64) {
|
} else if (process.env.DATABASE_KEY.length < 64) {
|
||||||
securityIssues.push("DATABASE_KEY should be at least 64 characters in production");
|
securityIssues.push(
|
||||||
|
"DATABASE_KEY should be at least 64 characters in production",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.INTERNAL_AUTH_TOKEN) {
|
if (!process.env.INTERNAL_AUTH_TOKEN) {
|
||||||
systemLogger.warn("INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)", {
|
systemLogger.warn(
|
||||||
|
"INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)",
|
||||||
|
{
|
||||||
operation: "security_warning",
|
operation: "security_warning",
|
||||||
note: "Auto-generated tokens are secure but not persistent across deployments"
|
note: "Auto-generated tokens are secure but not persistent across deployments",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
|
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
|
||||||
securityIssues.push("INTERNAL_AUTH_TOKEN should be at least 32 characters in production");
|
securityIssues.push(
|
||||||
|
"INTERNAL_AUTH_TOKEN should be at least 32 characters in production",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check database file encryption
|
if (process.env.DB_FILE_ENCRYPTION === "false") {
|
||||||
if (process.env.DB_FILE_ENCRYPTION === 'false') {
|
securityIssues.push(
|
||||||
securityIssues.push("Database file encryption should be enabled in production");
|
"Database file encryption should be enabled in production",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemLogger.warn(
|
||||||
// Check CORS configuration warning
|
"Production deployment detected - ensure CORS is properly configured",
|
||||||
systemLogger.warn("Production deployment detected - ensure CORS is properly configured", {
|
{
|
||||||
operation: "security_checks",
|
operation: "security_checks",
|
||||||
warning: "Verify frontend domain whitelist"
|
warning: "Verify frontend domain whitelist",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (securityIssues.length > 0) {
|
if (securityIssues.length > 0) {
|
||||||
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
|
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
|
||||||
@@ -127,59 +107,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
});
|
});
|
||||||
process.exit(1);
|
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 simplified authentication system
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
DataCrypto.initialize();
|
DataCrypto.initialize();
|
||||||
|
|
||||||
// System crypto keys already initialized above
|
|
||||||
|
|
||||||
systemLogger.info("Security system initialized (KEK-DEK architecture + SystemCrypto)", {
|
|
||||||
operation: "security_init",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load database-dependent modules after database initialization
|
|
||||||
systemLogger.info("Starting database API server...", {
|
|
||||||
operation: "api_server_init"
|
|
||||||
});
|
|
||||||
await import("./database/database.js");
|
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/terminal.js");
|
||||||
await import("./ssh/tunnel.js");
|
await import("./ssh/tunnel.js");
|
||||||
await import("./ssh/file-manager.js");
|
await import("./ssh/file-manager.js");
|
||||||
await import("./ssh/server-stats.js");
|
await import("./ssh/server-stats.js");
|
||||||
|
|
||||||
systemLogger.success("All backend services initialized successfully", {
|
|
||||||
operation: "startup_complete",
|
|
||||||
services: [
|
|
||||||
"database",
|
|
||||||
"encryption",
|
|
||||||
"terminal",
|
|
||||||
"tunnel",
|
|
||||||
"file_manager",
|
|
||||||
"stats",
|
|
||||||
],
|
|
||||||
version: version,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Display SSL configuration info
|
|
||||||
AutoSSLSetup.logSSLInfo();
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
systemLogger.info(
|
systemLogger.info(
|
||||||
"Received SIGINT signal, initiating graceful shutdown...",
|
"Received SIGINT signal, initiating graceful shutdown...",
|
||||||
|
|||||||
@@ -23,27 +23,16 @@ interface JWTPayload {
|
|||||||
exp?: 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 {
|
class AuthManager {
|
||||||
private static instance: AuthManager;
|
private static instance: AuthManager;
|
||||||
private systemCrypto: SystemCrypto;
|
private systemCrypto: SystemCrypto;
|
||||||
private userCrypto: UserCrypto;
|
private userCrypto: UserCrypto;
|
||||||
private invalidatedTokens: Set<string> = new Set(); // Track invalidated JWT tokens
|
private invalidatedTokens: Set<string> = new Set();
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.systemCrypto = SystemCrypto.getInstance();
|
this.systemCrypto = SystemCrypto.getInstance();
|
||||||
this.userCrypto = UserCrypto.getInstance();
|
this.userCrypto = UserCrypto.getInstance();
|
||||||
|
|
||||||
// Set up callback to invalidate JWT tokens when data sessions expire
|
|
||||||
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
||||||
this.invalidateUserTokens(userId);
|
this.invalidateUserTokens(userId);
|
||||||
});
|
});
|
||||||
@@ -56,80 +45,58 @@ class AuthManager {
|
|||||||
return this.instance;
|
return this.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize authentication system
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
await this.systemCrypto.initializeJWTSecret();
|
await this.systemCrypto.initializeJWTSecret();
|
||||||
databaseLogger.info("AuthManager initialized", {
|
|
||||||
operation: "auth_init"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* User registration
|
|
||||||
*/
|
|
||||||
async registerUser(userId: string, password: string): Promise<void> {
|
async registerUser(userId: string, password: string): Promise<void> {
|
||||||
await this.userCrypto.setupUserEncryption(userId, password);
|
await this.userCrypto.setupUserEncryption(userId, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* User login with lazy encryption migration
|
|
||||||
*/
|
|
||||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||||
const authenticated = await this.userCrypto.authenticateUser(userId, password);
|
const authenticated = await this.userCrypto.authenticateUser(
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
// Trigger lazy encryption migration for user's sensitive fields
|
|
||||||
await this.performLazyEncryptionMigration(userId);
|
await this.performLazyEncryptionMigration(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticated;
|
return authenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform lazy encryption migration for user's sensitive data
|
|
||||||
* This runs asynchronously after successful login
|
|
||||||
*/
|
|
||||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userDataKey = this.getUserDataKey(userId);
|
const userDataKey = this.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
databaseLogger.warn("Cannot perform lazy encryption migration - user data key not available", {
|
databaseLogger.warn(
|
||||||
|
"Cannot perform lazy encryption migration - user data key not available",
|
||||||
|
{
|
||||||
operation: "lazy_encryption_migration_no_key",
|
operation: "lazy_encryption_migration_no_key",
|
||||||
userId,
|
userId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import database connection - need to access raw SQLite for migration
|
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||||
const { getSqlite, saveMemoryDatabaseToFile } = await import("../database/db/index.js");
|
"../database/db/index.js"
|
||||||
|
);
|
||||||
|
|
||||||
// Database should already be initialized by starter.ts, but ensure we can access it
|
|
||||||
const sqlite = getSqlite();
|
const sqlite = getSqlite();
|
||||||
|
|
||||||
// Perform the migration
|
|
||||||
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
||||||
userId,
|
userId,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
sqlite
|
sqlite,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (migrationResult.migrated) {
|
if (migrationResult.migrated) {
|
||||||
// Save the in-memory database to disk to persist the migration
|
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
|
|
||||||
databaseLogger.success("Lazy encryption migration completed for user", {
|
|
||||||
operation: "lazy_encryption_migration_success",
|
|
||||||
userId,
|
|
||||||
migratedTables: migrationResult.migratedTables,
|
|
||||||
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error but don't fail the login process
|
|
||||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||||
operation: "lazy_encryption_migration_error",
|
operation: "lazy_encryption_migration_error",
|
||||||
userId,
|
userId,
|
||||||
@@ -138,12 +105,9 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate JWT Token
|
|
||||||
*/
|
|
||||||
async generateJWTToken(
|
async generateJWTToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {}
|
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
|
|
||||||
@@ -153,21 +117,13 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return jwt.sign(payload, jwtSecret, {
|
return jwt.sign(payload, jwtSecret, {
|
||||||
expiresIn: options.expiresIn || "24h"
|
expiresIn: options.expiresIn || "24h",
|
||||||
} as jwt.SignOptions);
|
} as jwt.SignOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT Token
|
|
||||||
*/
|
|
||||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||||
try {
|
try {
|
||||||
// Check if token is in invalidated list
|
|
||||||
if (this.invalidatedTokens.has(token)) {
|
if (this.invalidatedTokens.has(token)) {
|
||||||
databaseLogger.debug("JWT token is invalidated", {
|
|
||||||
operation: "jwt_verify_invalidated",
|
|
||||||
tokenPrefix: token.substring(0, 20) + "..."
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,58 +133,37 @@ class AuthManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("JWT verification failed", {
|
databaseLogger.warn("JWT verification failed", {
|
||||||
operation: "jwt_verify_failed",
|
operation: "jwt_verify_failed",
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate JWT token (add to blacklist)
|
|
||||||
*/
|
|
||||||
invalidateJWTToken(token: string): void {
|
invalidateJWTToken(token: string): void {
|
||||||
this.invalidatedTokens.add(token);
|
this.invalidatedTokens.add(token);
|
||||||
databaseLogger.info("JWT token invalidated", {
|
|
||||||
operation: "jwt_invalidate",
|
|
||||||
tokenPrefix: token.substring(0, 20) + "..."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all JWT tokens for a user (when data locks)
|
|
||||||
*/
|
|
||||||
invalidateUserTokens(userId: string): void {
|
invalidateUserTokens(userId: string): void {
|
||||||
// Note: This is a simplified approach. In a production system, you might want
|
|
||||||
// to track tokens by userId and invalidate them more precisely.
|
|
||||||
// For now, we'll rely on the data lock mechanism to handle this.
|
|
||||||
databaseLogger.info("User tokens invalidated due to data lock", {
|
databaseLogger.info("User tokens invalidated due to data lock", {
|
||||||
operation: "user_tokens_invalidate",
|
operation: "user_tokens_invalidate",
|
||||||
userId
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get secure cookie options based on request
|
|
||||||
*/
|
|
||||||
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
|
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true, // Prevent XSS attacks
|
httpOnly: true,
|
||||||
secure: req.secure || req.headers['x-forwarded-proto'] === 'https', // Detect HTTPS properly
|
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||||
sameSite: "strict" as const, // Prevent CSRF attacks
|
sameSite: "strict" as const,
|
||||||
maxAge: maxAge, // Session duration in milliseconds
|
maxAge: maxAge,
|
||||||
path: "/", // Available site-wide
|
path: "/",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication middleware
|
|
||||||
*/
|
|
||||||
createAuthMiddleware() {
|
createAuthMiddleware() {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Try to get JWT from secure HttpOnly cookie first
|
|
||||||
let token = req.cookies?.jwt;
|
let token = req.cookies?.jwt;
|
||||||
|
|
||||||
// Fallback to Authorization header for backward compatibility
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
@@ -252,9 +187,6 @@ class AuthManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Data access middleware - requires user to have unlocked data
|
|
||||||
*/
|
|
||||||
createDataAccessMiddleware() {
|
createDataAccessMiddleware() {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
@@ -266,7 +198,7 @@ class AuthManager {
|
|||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED"
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +207,6 @@ class AuthManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin middleware - requires user to be authenticated and have admin privileges
|
|
||||||
*/
|
|
||||||
createAdminMiddleware() {
|
createAdminMiddleware() {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
@@ -292,20 +221,25 @@ class AuthManager {
|
|||||||
return res.status(401).json({ error: "Invalid token" });
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
try {
|
try {
|
||||||
const { db } = await import("../database/db/index.js");
|
const { db } = await import("../database/db/index.js");
|
||||||
const { users } = await import("../database/db/schema.js");
|
const { users } = await import("../database/db/schema.js");
|
||||||
const { eq } = await import("drizzle-orm");
|
const { eq } = await import("drizzle-orm");
|
||||||
|
|
||||||
const user = await db.select().from(users).where(eq(users.id, payload.userId));
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, payload.userId));
|
||||||
|
|
||||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||||
databaseLogger.warn("Non-admin user attempted to access admin endpoint", {
|
databaseLogger.warn(
|
||||||
|
"Non-admin user attempted to access admin endpoint",
|
||||||
|
{
|
||||||
operation: "admin_access_denied",
|
operation: "admin_access_denied",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
endpoint: req.path,
|
endpoint: req.path,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return res.status(403).json({ error: "Admin access required" });
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,37 +251,35 @@ class AuthManager {
|
|||||||
operation: "admin_check_failed",
|
operation: "admin_check_failed",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
});
|
});
|
||||||
return res.status(500).json({ error: "Failed to verify admin privileges" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to verify admin privileges" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* User logout
|
|
||||||
*/
|
|
||||||
logoutUser(userId: string): void {
|
logoutUser(userId: string): void {
|
||||||
this.userCrypto.logoutUser(userId);
|
this.userCrypto.logoutUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data key
|
|
||||||
*/
|
|
||||||
getUserDataKey(userId: string): Buffer | null {
|
getUserDataKey(userId: string): Buffer | null {
|
||||||
return this.userCrypto.getUserDataKey(userId);
|
return this.userCrypto.getUserDataKey(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is unlocked
|
|
||||||
*/
|
|
||||||
isUserUnlocked(userId: string): boolean {
|
isUserUnlocked(userId: string): boolean {
|
||||||
return this.userCrypto.isUserUnlocked(userId);
|
return this.userCrypto.isUserUnlocked(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async changeUserPassword(
|
||||||
* Change user password
|
userId: string,
|
||||||
*/
|
oldPassword: string,
|
||||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
newPassword: string,
|
||||||
return await this.userCrypto.changeUserPassword(userId, oldPassword, newPassword);
|
): Promise<boolean> {
|
||||||
|
return await this.userCrypto.changeUserPassword(
|
||||||
|
userId,
|
||||||
|
oldPassword,
|
||||||
|
newPassword,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,123 +4,92 @@ import path from "path";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { systemLogger } from "./logger.js";
|
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 {
|
export class AutoSSLSetup {
|
||||||
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
|
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
|
||||||
private static readonly CERT_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.crt");
|
private static readonly CERT_FILE = path.join(
|
||||||
private static readonly KEY_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.key");
|
AutoSSLSetup.SSL_DIR,
|
||||||
|
"termix.crt",
|
||||||
|
);
|
||||||
|
private static readonly KEY_FILE = path.join(
|
||||||
|
AutoSSLSetup.SSL_DIR,
|
||||||
|
"termix.key",
|
||||||
|
);
|
||||||
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
|
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize SSL setup automatically during system startup
|
|
||||||
*/
|
|
||||||
static async initialize(): Promise<void> {
|
static async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
systemLogger.info("Initializing SSL/TLS configuration...", {
|
|
||||||
operation: "ssl_auto_init"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if SSL is already properly configured
|
|
||||||
if (await this.isSSLConfigured()) {
|
if (await this.isSSLConfigured()) {
|
||||||
systemLogger.info("SSL configuration already exists and is valid", {
|
|
||||||
operation: "ssl_already_configured"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log certificate information for existing certificates
|
|
||||||
await this.logCertificateInfo();
|
await this.logCertificateInfo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate SSL certificates
|
|
||||||
await this.generateSSLCertificates();
|
await this.generateSSLCertificates();
|
||||||
|
|
||||||
// Setup environment variables for SSL
|
|
||||||
await this.setupEnvironmentVariables();
|
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) {
|
} catch (error) {
|
||||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||||
operation: "ssl_auto_init_failed"
|
operation: "ssl_auto_init_failed",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't crash the application - fallback to HTTP
|
|
||||||
systemLogger.warn("Falling back to HTTP-only mode", {
|
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||||
operation: "ssl_fallback_http"
|
operation: "ssl_fallback_http",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if SSL is already properly configured
|
|
||||||
*/
|
|
||||||
private static async isSSLConfigured(): Promise<boolean> {
|
private static async isSSLConfigured(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Check if certificate files exist
|
|
||||||
await fs.access(this.CERT_FILE);
|
await fs.access(this.CERT_FILE);
|
||||||
await fs.access(this.KEY_FILE);
|
await fs.access(this.KEY_FILE);
|
||||||
|
|
||||||
// Check if certificate is still valid (at least 30 days)
|
execSync(
|
||||||
execSync(`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, {
|
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
|
||||||
stdio: 'pipe'
|
{
|
||||||
});
|
stdio: "pipe",
|
||||||
|
},
|
||||||
systemLogger.info("SSL certificate is valid and will expire in more than 30 days", {
|
);
|
||||||
operation: "ssl_cert_check",
|
|
||||||
cert_path: this.CERT_FILE
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes('checkend')) {
|
if (error instanceof Error && error.message.includes("checkend")) {
|
||||||
systemLogger.warn("SSL certificate is expired or expiring soon, will regenerate", {
|
systemLogger.warn(
|
||||||
|
"SSL certificate is expired or expiring soon, will regenerate",
|
||||||
|
{
|
||||||
operation: "ssl_cert_expired",
|
operation: "ssl_cert_expired",
|
||||||
cert_path: this.CERT_FILE,
|
cert_path: this.CERT_FILE,
|
||||||
error: error.message
|
error: error.message,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
systemLogger.info("SSL certificate not found or invalid, will generate new one", {
|
systemLogger.info(
|
||||||
|
"SSL certificate not found or invalid, will generate new one",
|
||||||
|
{
|
||||||
operation: "ssl_cert_missing",
|
operation: "ssl_cert_missing",
|
||||||
cert_path: this.CERT_FILE
|
cert_path: this.CERT_FILE,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SSL certificates automatically
|
|
||||||
*/
|
|
||||||
private static async generateSSLCertificates(): Promise<void> {
|
private static async generateSSLCertificates(): Promise<void> {
|
||||||
systemLogger.info("Generating SSL certificates for local development...", {
|
systemLogger.info("Generating SSL certificates for local development...", {
|
||||||
operation: "ssl_cert_generation"
|
operation: "ssl_cert_generation",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if OpenSSL is available
|
|
||||||
try {
|
try {
|
||||||
execSync('openssl version', { stdio: 'pipe' });
|
execSync("openssl version", { stdio: "pipe" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error('OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.');
|
throw new Error(
|
||||||
|
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create SSL directory
|
|
||||||
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
||||||
|
|
||||||
// Create OpenSSL config for comprehensive certificate
|
|
||||||
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
||||||
const opensslConfig = `
|
const opensslConfig = `
|
||||||
[req]
|
[req]
|
||||||
@@ -155,102 +124,106 @@ IP.2 = ::1
|
|||||||
|
|
||||||
await fs.writeFile(configFile, opensslConfig);
|
await fs.writeFile(configFile, opensslConfig);
|
||||||
|
|
||||||
// Generate private key
|
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
|
||||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, { stdio: 'pipe' });
|
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
|
execSync(
|
||||||
|
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await fs.chmod(this.KEY_FILE, 0o600);
|
await fs.chmod(this.KEY_FILE, 0o600);
|
||||||
await fs.chmod(this.CERT_FILE, 0o644);
|
await fs.chmod(this.CERT_FILE, 0o644);
|
||||||
|
|
||||||
// Clean up temp config
|
|
||||||
await fs.unlink(configFile);
|
await fs.unlink(configFile);
|
||||||
|
|
||||||
systemLogger.success("SSL certificates generated successfully", {
|
systemLogger.success("SSL certificates generated successfully", {
|
||||||
operation: "ssl_cert_generated",
|
operation: "ssl_cert_generated",
|
||||||
cert_path: this.CERT_FILE,
|
cert_path: this.CERT_FILE,
|
||||||
key_path: this.KEY_FILE,
|
key_path: this.KEY_FILE,
|
||||||
valid_days: 365
|
valid_days: 365,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log certificate information
|
|
||||||
await this.logCertificateInfo();
|
await this.logCertificateInfo();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`SSL certificate generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(
|
||||||
|
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log certificate information including expiration date
|
|
||||||
*/
|
|
||||||
private static async logCertificateInfo(): Promise<void> {
|
private static async logCertificateInfo(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const subject = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -subject`, { stdio: 'pipe' }).toString().trim();
|
const subject = execSync(
|
||||||
const issuer = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`, { stdio: 'pipe' }).toString().trim();
|
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
|
||||||
const notAfter = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`, { stdio: 'pipe' }).toString().trim();
|
{ stdio: "pipe" },
|
||||||
const notBefore = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`, { stdio: 'pipe' }).toString().trim();
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const issuer = execSync(
|
||||||
|
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const notAfter = execSync(
|
||||||
|
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const notBefore = execSync(
|
||||||
|
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
systemLogger.info("SSL Certificate Information:", {
|
systemLogger.info("SSL Certificate Information:", {
|
||||||
operation: "ssl_cert_info",
|
operation: "ssl_cert_info",
|
||||||
subject: subject.replace('subject=', ''),
|
subject: subject.replace("subject=", ""),
|
||||||
issuer: issuer.replace('issuer=', ''),
|
issuer: issuer.replace("issuer=", ""),
|
||||||
valid_from: notBefore.replace('notBefore=', ''),
|
valid_from: notBefore.replace("notBefore=", ""),
|
||||||
valid_until: notAfter.replace('notAfter=', ''),
|
valid_until: notAfter.replace("notAfter=", ""),
|
||||||
note: "Certificate will auto-renew 30 days before expiration"
|
note: "Certificate will auto-renew 30 days before expiration",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.warn("Could not retrieve certificate information", {
|
systemLogger.warn("Could not retrieve certificate information", {
|
||||||
operation: "ssl_cert_info_error",
|
operation: "ssl_cert_info_error",
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup environment variables for SSL configuration
|
|
||||||
*/
|
|
||||||
private static async setupEnvironmentVariables(): Promise<void> {
|
private static async setupEnvironmentVariables(): Promise<void> {
|
||||||
systemLogger.info("Configuring SSL environment variables...", {
|
|
||||||
operation: "ssl_env_setup"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use data directory paths for both production and development
|
|
||||||
const certPath = this.CERT_FILE;
|
const certPath = this.CERT_FILE;
|
||||||
const keyPath = this.KEY_FILE;
|
const keyPath = this.KEY_FILE;
|
||||||
|
|
||||||
const sslEnvVars = {
|
const sslEnvVars = {
|
||||||
ENABLE_SSL: "false", // Disable SSL by default to avoid setup issues
|
ENABLE_SSL: "false",
|
||||||
SSL_PORT: process.env.SSL_PORT || "8443",
|
SSL_PORT: process.env.SSL_PORT || "8443",
|
||||||
SSL_CERT_PATH: certPath,
|
SSL_CERT_PATH: certPath,
|
||||||
SSL_KEY_PATH: keyPath,
|
SSL_KEY_PATH: keyPath,
|
||||||
SSL_DOMAIN: "localhost"
|
SSL_DOMAIN: "localhost",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if .env file exists
|
|
||||||
let envContent = "";
|
let envContent = "";
|
||||||
try {
|
try {
|
||||||
envContent = await fs.readFile(this.ENV_FILE, 'utf8');
|
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||||
} catch {
|
} catch {}
|
||||||
// .env doesn't exist, will create new one
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or add SSL variables
|
|
||||||
let updatedContent = envContent;
|
let updatedContent = envContent;
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
const regex = new RegExp(`^${key}=.*$`, "m");
|
||||||
|
|
||||||
if (regex.test(updatedContent)) {
|
if (regex.test(updatedContent)) {
|
||||||
// Update existing variable
|
|
||||||
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
||||||
} else {
|
} else {
|
||||||
// Add new variable
|
|
||||||
if (!updatedContent.includes(`# SSL Configuration`)) {
|
if (!updatedContent.includes(`# SSL Configuration`)) {
|
||||||
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
||||||
}
|
}
|
||||||
@@ -259,59 +232,28 @@ IP.2 = ::1
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write updated .env file if there are changes
|
|
||||||
if (hasChanges || !envContent) {
|
if (hasChanges || !envContent) {
|
||||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
|
||||||
|
|
||||||
systemLogger.info("SSL environment variables configured", {
|
systemLogger.info("SSL environment variables configured", {
|
||||||
operation: "ssl_env_configured",
|
operation: "ssl_env_configured",
|
||||||
file: this.ENV_FILE,
|
file: this.ENV_FILE,
|
||||||
variables: Object.keys(sslEnvVars)
|
variables: Object.keys(sslEnvVars),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update process.env for current session
|
|
||||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SSL configuration for nginx/server
|
|
||||||
*/
|
|
||||||
static getSSLConfig() {
|
static getSSLConfig() {
|
||||||
return {
|
return {
|
||||||
enabled: process.env.ENABLE_SSL === "true",
|
enabled: process.env.ENABLE_SSL === "true",
|
||||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||||
domain: process.env.SSL_DOMAIN || "localhost"
|
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 ║
|
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,31 +3,21 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js";
|
|||||||
import { UserCrypto } from "./user-crypto.js";
|
import { UserCrypto } from "./user-crypto.js";
|
||||||
import { databaseLogger } from "./logger.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 {
|
class DataCrypto {
|
||||||
private static userCrypto: UserCrypto;
|
private static userCrypto: UserCrypto;
|
||||||
|
|
||||||
static initialize() {
|
static initialize() {
|
||||||
this.userCrypto = UserCrypto.getInstance();
|
this.userCrypto = UserCrypto.getInstance();
|
||||||
databaseLogger.info("DataCrypto initialized - no legacy compatibility", {
|
|
||||||
operation: "data_crypto_init",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static encryptRecord(
|
||||||
* Encrypt record - simple and direct
|
tableName: string,
|
||||||
*/
|
record: any,
|
||||||
static encryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
userId: string,
|
||||||
|
userDataKey: Buffer,
|
||||||
|
): any {
|
||||||
const encryptedRecord = { ...record };
|
const encryptedRecord = { ...record };
|
||||||
const recordId = record.id || 'temp-' + Date.now();
|
const recordId = record.id || "temp-" + Date.now();
|
||||||
|
|
||||||
for (const [fieldName, value] of Object.entries(record)) {
|
for (const [fieldName, value] of Object.entries(record)) {
|
||||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||||
@@ -35,7 +25,7 @@ class DataCrypto {
|
|||||||
value as string,
|
value as string,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
recordId,
|
recordId,
|
||||||
fieldName
|
fieldName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,11 +33,12 @@ class DataCrypto {
|
|||||||
return encryptedRecord;
|
return encryptedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static decryptRecord(
|
||||||
* Decrypt record with lazy encryption support
|
tableName: string,
|
||||||
* Handles both encrypted and plaintext fields (from migration)
|
record: any,
|
||||||
*/
|
userId: string,
|
||||||
static decryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
userDataKey: Buffer,
|
||||||
|
): any {
|
||||||
if (!record) return record;
|
if (!record) return record;
|
||||||
|
|
||||||
const decryptedRecord = { ...record };
|
const decryptedRecord = { ...record };
|
||||||
@@ -55,12 +46,11 @@ class DataCrypto {
|
|||||||
|
|
||||||
for (const [fieldName, value] of Object.entries(record)) {
|
for (const [fieldName, value] of Object.entries(record)) {
|
||||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||||
// Use lazy encryption to handle both plaintext and encrypted data
|
|
||||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||||
value as string,
|
value as string,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
recordId,
|
recordId,
|
||||||
fieldName
|
fieldName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,22 +58,22 @@ class DataCrypto {
|
|||||||
return decryptedRecord;
|
return decryptedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static decryptRecords(
|
||||||
* Batch decrypt
|
tableName: string,
|
||||||
*/
|
records: any[],
|
||||||
static decryptRecords(tableName: string, records: any[], userId: string, userDataKey: Buffer): any[] {
|
userId: string,
|
||||||
|
userDataKey: Buffer,
|
||||||
|
): any[] {
|
||||||
if (!Array.isArray(records)) return records;
|
if (!Array.isArray(records)) return records;
|
||||||
return records.map((record) => this.decryptRecord(tableName, record, userId, userDataKey));
|
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(
|
static async migrateUserSensitiveFields(
|
||||||
userId: string,
|
userId: string,
|
||||||
userDataKey: Buffer,
|
userDataKey: Buffer,
|
||||||
db: any
|
db: any,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
migrated: boolean;
|
migrated: boolean;
|
||||||
migratedTables: string[];
|
migratedTables: string[];
|
||||||
@@ -94,45 +84,32 @@ class DataCrypto {
|
|||||||
let migratedFieldsCount = 0;
|
let migratedFieldsCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Starting user sensitive fields migration", {
|
const { needsMigration, plaintextFields } =
|
||||||
operation: "user_sensitive_migration_start",
|
await LazyFieldEncryption.checkUserNeedsMigration(
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if migration is needed
|
|
||||||
const { needsMigration, plaintextFields } = await LazyFieldEncryption.checkUserNeedsMigration(
|
|
||||||
userId,
|
userId,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
db
|
db,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!needsMigration) {
|
if (!needsMigration) {
|
||||||
databaseLogger.info("No migration needed for user", {
|
|
||||||
operation: "user_sensitive_migration_not_needed",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.info("User requires sensitive field migration", {
|
const sshDataRecords = db
|
||||||
operation: "user_sensitive_migration_required",
|
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||||
userId,
|
.all(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) {
|
for (const record of sshDataRecords) {
|
||||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_data');
|
const sensitiveFields =
|
||||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
|
||||||
|
const { updatedRecord, migratedFields, needsUpdate } =
|
||||||
|
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||||
record,
|
record,
|
||||||
sensitiveFields,
|
sensitiveFields,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
record.id.toString()
|
record.id.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
// Update the record in database
|
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE ssh_data
|
UPDATE ssh_data
|
||||||
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -142,30 +119,32 @@ class DataCrypto {
|
|||||||
updatedRecord.password || null,
|
updatedRecord.password || null,
|
||||||
updatedRecord.key || null,
|
updatedRecord.key || null,
|
||||||
updatedRecord.key_password || null,
|
updatedRecord.key_password || null,
|
||||||
record.id
|
record.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
migratedFieldsCount += migratedFields.length;
|
migratedFieldsCount += migratedFields.length;
|
||||||
if (!migratedTables.includes('ssh_data')) {
|
if (!migratedTables.includes("ssh_data")) {
|
||||||
migratedTables.push('ssh_data');
|
migratedTables.push("ssh_data");
|
||||||
}
|
}
|
||||||
migrated = true;
|
migrated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process ssh_credentials table
|
const sshCredentialsRecords = db
|
||||||
const sshCredentialsRecords = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||||
|
.all(userId);
|
||||||
for (const record of sshCredentialsRecords) {
|
for (const record of sshCredentialsRecords) {
|
||||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_credentials');
|
const sensitiveFields =
|
||||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
|
||||||
|
const { updatedRecord, migratedFields, needsUpdate } =
|
||||||
|
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||||
record,
|
record,
|
||||||
sensitiveFields,
|
sensitiveFields,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
record.id.toString()
|
record.id.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
// Update the record in database
|
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE ssh_credentials
|
UPDATE ssh_credentials
|
||||||
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -176,30 +155,32 @@ class DataCrypto {
|
|||||||
updatedRecord.key || null,
|
updatedRecord.key || null,
|
||||||
updatedRecord.key_password || null,
|
updatedRecord.key_password || null,
|
||||||
updatedRecord.private_key || null,
|
updatedRecord.private_key || null,
|
||||||
record.id
|
record.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
migratedFieldsCount += migratedFields.length;
|
migratedFieldsCount += migratedFields.length;
|
||||||
if (!migratedTables.includes('ssh_credentials')) {
|
if (!migratedTables.includes("ssh_credentials")) {
|
||||||
migratedTables.push('ssh_credentials');
|
migratedTables.push("ssh_credentials");
|
||||||
}
|
}
|
||||||
migrated = true;
|
migrated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process users table
|
const userRecord = db
|
||||||
const userRecord = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
.prepare("SELECT * FROM users WHERE id = ?")
|
||||||
|
.get(userId);
|
||||||
if (userRecord) {
|
if (userRecord) {
|
||||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('users');
|
const sensitiveFields =
|
||||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||||
|
const { updatedRecord, migratedFields, needsUpdate } =
|
||||||
|
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||||
userRecord,
|
userRecord,
|
||||||
sensitiveFields,
|
sensitiveFields,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
userId
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
// Update the record in database
|
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET totp_secret = ?, totp_backup_codes = ?
|
SET totp_secret = ?, totp_backup_codes = ?
|
||||||
@@ -208,28 +189,18 @@ class DataCrypto {
|
|||||||
db.prepare(updateQuery).run(
|
db.prepare(updateQuery).run(
|
||||||
updatedRecord.totp_secret || null,
|
updatedRecord.totp_secret || null,
|
||||||
updatedRecord.totp_backup_codes || null,
|
updatedRecord.totp_backup_codes || null,
|
||||||
userId
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
migratedFieldsCount += migratedFields.length;
|
migratedFieldsCount += migratedFields.length;
|
||||||
if (!migratedTables.includes('users')) {
|
if (!migratedTables.includes("users")) {
|
||||||
migratedTables.push('users');
|
migratedTables.push("users");
|
||||||
}
|
}
|
||||||
migrated = true;
|
migrated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (migrated) {
|
|
||||||
databaseLogger.success("User sensitive fields migration completed", {
|
|
||||||
operation: "user_sensitive_migration_success",
|
|
||||||
userId,
|
|
||||||
migratedTables,
|
|
||||||
migratedFieldsCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { migrated, migratedTables, migratedFieldsCount };
|
return { migrated, migratedTables, migratedFieldsCount };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||||
operation: "user_sensitive_migration_failed",
|
operation: "user_sensitive_migration_failed",
|
||||||
@@ -237,21 +208,14 @@ class DataCrypto {
|
|||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't throw error to avoid breaking user login
|
|
||||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data key
|
|
||||||
*/
|
|
||||||
static getUserDataKey(userId: string): Buffer | null {
|
static getUserDataKey(userId: string): Buffer | null {
|
||||||
return this.userCrypto.getUserDataKey(userId);
|
return this.userCrypto.getUserDataKey(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify user access permissions - simple and direct
|
|
||||||
*/
|
|
||||||
static validateUserAccess(userId: string): Buffer {
|
static validateUserAccess(userId: string): Buffer {
|
||||||
const userDataKey = this.getUserDataKey(userId);
|
const userDataKey = this.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
@@ -260,48 +224,55 @@ class DataCrypto {
|
|||||||
return userDataKey;
|
return userDataKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static encryptRecordForUser(
|
||||||
* Convenience method: automatically get user key and encrypt
|
tableName: string,
|
||||||
*/
|
record: any,
|
||||||
static encryptRecordForUser(tableName: string, record: any, userId: string): any {
|
userId: string,
|
||||||
|
): any {
|
||||||
const userDataKey = this.validateUserAccess(userId);
|
const userDataKey = this.validateUserAccess(userId);
|
||||||
return this.encryptRecord(tableName, record, userId, userDataKey);
|
return this.encryptRecord(tableName, record, userId, userDataKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static decryptRecordForUser(
|
||||||
* Convenience method: automatically get user key and decrypt
|
tableName: string,
|
||||||
*/
|
record: any,
|
||||||
static decryptRecordForUser(tableName: string, record: any, userId: string): any {
|
userId: string,
|
||||||
|
): any {
|
||||||
const userDataKey = this.validateUserAccess(userId);
|
const userDataKey = this.validateUserAccess(userId);
|
||||||
return this.decryptRecord(tableName, record, userId, userDataKey);
|
return this.decryptRecord(tableName, record, userId, userDataKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static decryptRecordsForUser(
|
||||||
* Convenience method: batch decrypt
|
tableName: string,
|
||||||
*/
|
records: any[],
|
||||||
static decryptRecordsForUser(tableName: string, records: any[], userId: string): any[] {
|
userId: string,
|
||||||
|
): any[] {
|
||||||
const userDataKey = this.validateUserAccess(userId);
|
const userDataKey = this.validateUserAccess(userId);
|
||||||
return this.decryptRecords(tableName, records, userId, userDataKey);
|
return this.decryptRecords(tableName, records, userId, userDataKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user can access data
|
|
||||||
*/
|
|
||||||
static canUserAccessData(userId: string): boolean {
|
static canUserAccessData(userId: string): boolean {
|
||||||
return this.userCrypto.isUserUnlocked(userId);
|
return this.userCrypto.isUserUnlocked(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Test encryption functionality
|
|
||||||
*/
|
|
||||||
static testUserEncryption(userId: string): boolean {
|
static testUserEncryption(userId: string): boolean {
|
||||||
try {
|
try {
|
||||||
const userDataKey = this.getUserDataKey(userId);
|
const userDataKey = this.getUserDataKey(userId);
|
||||||
if (!userDataKey) return false;
|
if (!userDataKey) return false;
|
||||||
|
|
||||||
const testData = "test-" + Date.now();
|
const testData = "test-" + Date.now();
|
||||||
const encrypted = FieldCrypto.encryptField(testData, userDataKey, "test-record", "test-field");
|
const encrypted = FieldCrypto.encryptField(
|
||||||
const decrypted = FieldCrypto.decryptField(encrypted, userDataKey, "test-record", "test-field");
|
testData,
|
||||||
|
userDataKey,
|
||||||
|
"test-record",
|
||||||
|
"test-field",
|
||||||
|
);
|
||||||
|
const decrypted = FieldCrypto.decryptField(
|
||||||
|
encrypted,
|
||||||
|
userDataKey,
|
||||||
|
"test-record",
|
||||||
|
"test-field",
|
||||||
|
);
|
||||||
|
|
||||||
return decrypted === testData;
|
return decrypted === testData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,19 +10,10 @@ interface EncryptedFileMetadata {
|
|||||||
version: string;
|
version: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
keySource?: string; // Track where the key comes from (SystemCrypto) - v2 only
|
keySource?: string;
|
||||||
salt?: string; // Legacy v1 format only
|
salt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Database file encryption - encrypts the entire SQLite database file at rest
|
|
||||||
* 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 {
|
class DatabaseFileEncryption {
|
||||||
private static readonly VERSION = "v2";
|
private static readonly VERSION = "v2";
|
||||||
private static readonly ALGORITHM = "aes-256-gcm";
|
private static readonly ALGORITHM = "aes-256-gcm";
|
||||||
@@ -30,33 +21,28 @@ class DatabaseFileEncryption {
|
|||||||
private static readonly METADATA_FILE_SUFFIX = ".meta";
|
private static readonly METADATA_FILE_SUFFIX = ".meta";
|
||||||
private static systemCrypto = SystemCrypto.getInstance();
|
private static systemCrypto = SystemCrypto.getInstance();
|
||||||
|
|
||||||
/**
|
static async encryptDatabaseFromBuffer(
|
||||||
* Encrypt database from buffer (for in-memory databases)
|
buffer: Buffer,
|
||||||
*/
|
targetPath: string,
|
||||||
static async encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
|
||||||
const key = await this.systemCrypto.getDatabaseKey();
|
const key = await this.systemCrypto.getDatabaseKey();
|
||||||
|
|
||||||
// Generate encryption components
|
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
// Encrypt the buffer
|
|
||||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||||
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Create metadata
|
|
||||||
const metadata: EncryptedFileMetadata = {
|
const metadata: EncryptedFileMetadata = {
|
||||||
iv: iv.toString("hex"),
|
iv: iv.toString("hex"),
|
||||||
tag: tag.toString("hex"),
|
tag: tag.toString("hex"),
|
||||||
version: this.VERSION,
|
version: this.VERSION,
|
||||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
fingerprint: "termix-v2-systemcrypto",
|
||||||
algorithm: this.ALGORITHM,
|
algorithm: this.ALGORITHM,
|
||||||
keySource: "SystemCrypto",
|
keySource: "SystemCrypto",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write encrypted file and metadata
|
|
||||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
fs.writeFileSync(targetPath, encrypted);
|
fs.writeFileSync(targetPath, encrypted);
|
||||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
@@ -73,10 +59,10 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static async encryptDatabaseFile(
|
||||||
* Encrypt database file
|
sourcePath: string,
|
||||||
*/
|
targetPath?: string,
|
||||||
static async encryptDatabaseFile(sourcePath: string, targetPath?: string): Promise<string> {
|
): Promise<string> {
|
||||||
if (!fs.existsSync(sourcePath)) {
|
if (!fs.existsSync(sourcePath)) {
|
||||||
throw new Error(`Source database file does not exist: ${sourcePath}`);
|
throw new Error(`Source database file does not exist: ${sourcePath}`);
|
||||||
}
|
}
|
||||||
@@ -86,16 +72,12 @@ class DatabaseFileEncryption {
|
|||||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read source file
|
|
||||||
const sourceData = fs.readFileSync(sourcePath);
|
const sourceData = fs.readFileSync(sourcePath);
|
||||||
|
|
||||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
|
||||||
const key = await this.systemCrypto.getDatabaseKey();
|
const key = await this.systemCrypto.getDatabaseKey();
|
||||||
|
|
||||||
// Generate encryption components
|
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
// Encrypt the file
|
|
||||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||||
const encrypted = Buffer.concat([
|
const encrypted = Buffer.concat([
|
||||||
cipher.update(sourceData),
|
cipher.update(sourceData),
|
||||||
@@ -103,17 +85,15 @@ class DatabaseFileEncryption {
|
|||||||
]);
|
]);
|
||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Create metadata
|
|
||||||
const metadata: EncryptedFileMetadata = {
|
const metadata: EncryptedFileMetadata = {
|
||||||
iv: iv.toString("hex"),
|
iv: iv.toString("hex"),
|
||||||
tag: tag.toString("hex"),
|
tag: tag.toString("hex"),
|
||||||
version: this.VERSION,
|
version: this.VERSION,
|
||||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
fingerprint: "termix-v2-systemcrypto",
|
||||||
algorithm: this.ALGORITHM,
|
algorithm: this.ALGORITHM,
|
||||||
keySource: "SystemCrypto",
|
keySource: "SystemCrypto",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write encrypted file and metadata
|
|
||||||
fs.writeFileSync(encryptedPath, encrypted);
|
fs.writeFileSync(encryptedPath, encrypted);
|
||||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
|
||||||
@@ -139,9 +119,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt database file to buffer (for in-memory usage)
|
|
||||||
*/
|
|
||||||
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
|
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
|
||||||
if (!fs.existsSync(encryptedPath)) {
|
if (!fs.existsSync(encryptedPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -155,35 +132,33 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read metadata
|
|
||||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
// Read encrypted data
|
|
||||||
const encryptedData = fs.readFileSync(encryptedPath);
|
const encryptedData = fs.readFileSync(encryptedPath);
|
||||||
|
|
||||||
// Get decryption key based on version
|
|
||||||
let key: Buffer;
|
let key: Buffer;
|
||||||
if (metadata.version === "v2") {
|
if (metadata.version === "v2") {
|
||||||
// New v2 format: use SystemCrypto key
|
|
||||||
key = await this.systemCrypto.getDatabaseKey();
|
key = await this.systemCrypto.getDatabaseKey();
|
||||||
} else if (metadata.version === "v1") {
|
} else if (metadata.version === "v1") {
|
||||||
// Legacy v1 format: use deprecated salt-based key derivation
|
databaseLogger.warn(
|
||||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||||
|
{
|
||||||
operation: "decrypt_legacy_v1",
|
operation: "decrypt_legacy_v1",
|
||||||
path: encryptedPath
|
path: encryptedPath,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!metadata.salt) {
|
if (!metadata.salt) {
|
||||||
throw new Error("v1 encrypted file missing required salt field");
|
throw new Error("v1 encrypted file missing required salt field");
|
||||||
}
|
}
|
||||||
const salt = Buffer.from(metadata.salt, "hex");
|
const salt = Buffer.from(metadata.salt, "hex");
|
||||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
const fixedSeed =
|
||||||
|
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt to buffer
|
|
||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
metadata.algorithm,
|
metadata.algorithm,
|
||||||
key,
|
key,
|
||||||
@@ -208,9 +183,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt database file
|
|
||||||
*/
|
|
||||||
static async decryptDatabaseFile(
|
static async decryptDatabaseFile(
|
||||||
encryptedPath: string,
|
encryptedPath: string,
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
@@ -230,35 +202,33 @@ class DatabaseFileEncryption {
|
|||||||
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
|
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read metadata
|
|
||||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
// Read encrypted data
|
|
||||||
const encryptedData = fs.readFileSync(encryptedPath);
|
const encryptedData = fs.readFileSync(encryptedPath);
|
||||||
|
|
||||||
// Get decryption key based on version
|
|
||||||
let key: Buffer;
|
let key: Buffer;
|
||||||
if (metadata.version === "v2") {
|
if (metadata.version === "v2") {
|
||||||
// New v2 format: use SystemCrypto key
|
|
||||||
key = await this.systemCrypto.getDatabaseKey();
|
key = await this.systemCrypto.getDatabaseKey();
|
||||||
} else if (metadata.version === "v1") {
|
} else if (metadata.version === "v1") {
|
||||||
// Legacy v1 format: use deprecated salt-based key derivation
|
databaseLogger.warn(
|
||||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||||
|
{
|
||||||
operation: "decrypt_legacy_v1",
|
operation: "decrypt_legacy_v1",
|
||||||
path: encryptedPath
|
path: encryptedPath,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!metadata.salt) {
|
if (!metadata.salt) {
|
||||||
throw new Error("v1 encrypted file missing required salt field");
|
throw new Error("v1 encrypted file missing required salt field");
|
||||||
}
|
}
|
||||||
const salt = Buffer.from(metadata.salt, "hex");
|
const salt = Buffer.from(metadata.salt, "hex");
|
||||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
const fixedSeed =
|
||||||
|
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the file
|
|
||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
metadata.algorithm,
|
metadata.algorithm,
|
||||||
key,
|
key,
|
||||||
@@ -271,7 +241,6 @@ class DatabaseFileEncryption {
|
|||||||
decipher.final(),
|
decipher.final(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Write decrypted file
|
|
||||||
fs.writeFileSync(decryptedPath, decrypted);
|
fs.writeFileSync(decryptedPath, decrypted);
|
||||||
|
|
||||||
databaseLogger.info("Database file decrypted successfully", {
|
databaseLogger.info("Database file decrypted successfully", {
|
||||||
@@ -296,9 +265,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file is an encrypted database file
|
|
||||||
*/
|
|
||||||
static isEncryptedDatabaseFile(filePath: string): boolean {
|
static isEncryptedDatabaseFile(filePath: string): boolean {
|
||||||
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
|
|
||||||
@@ -318,9 +284,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get information about an encrypted database file
|
|
||||||
*/
|
|
||||||
static getEncryptedFileInfo(encryptedPath: string): {
|
static getEncryptedFileInfo(encryptedPath: string): {
|
||||||
version: string;
|
version: string;
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
@@ -338,13 +301,13 @@ class DatabaseFileEncryption {
|
|||||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||||
|
|
||||||
const fileStats = fs.statSync(encryptedPath);
|
const fileStats = fs.statSync(encryptedPath);
|
||||||
const currentFingerprint = "termix-v1-file"; // Fixed identifier
|
const currentFingerprint = "termix-v1-file";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: metadata.version,
|
version: metadata.version,
|
||||||
algorithm: metadata.algorithm,
|
algorithm: metadata.algorithm,
|
||||||
fingerprint: metadata.fingerprint,
|
fingerprint: metadata.fingerprint,
|
||||||
isCurrentHardware: true, // Hardware validation removed
|
isCurrentHardware: true,
|
||||||
fileSize: fileStats.size,
|
fileSize: fileStats.size,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -352,9 +315,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Securely backup database by creating encrypted copy
|
|
||||||
*/
|
|
||||||
static async createEncryptedBackup(
|
static async createEncryptedBackup(
|
||||||
databasePath: string,
|
databasePath: string,
|
||||||
backupDir: string,
|
backupDir: string,
|
||||||
@@ -363,25 +323,19 @@ class DatabaseFileEncryption {
|
|||||||
throw new Error(`Database file does not exist: ${databasePath}`);
|
throw new Error(`Database file does not exist: ${databasePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure backup directory exists
|
|
||||||
if (!fs.existsSync(backupDir)) {
|
if (!fs.existsSync(backupDir)) {
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate backup filename with timestamp
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
||||||
const backupPath = path.join(backupDir, backupFileName);
|
const backupPath = path.join(backupDir, backupFileName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedPath = await this.encryptDatabaseFile(databasePath, backupPath);
|
const encryptedPath = await this.encryptDatabaseFile(
|
||||||
|
databasePath,
|
||||||
databaseLogger.info("Encrypted database backup created", {
|
backupPath,
|
||||||
operation: "database_backup",
|
);
|
||||||
sourcePath: databasePath,
|
|
||||||
backupPath: encryptedPath,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
return encryptedPath;
|
return encryptedPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -394,9 +348,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore database from encrypted backup
|
|
||||||
*/
|
|
||||||
static async restoreFromEncryptedBackup(
|
static async restoreFromEncryptedBackup(
|
||||||
backupPath: string,
|
backupPath: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
@@ -406,13 +357,10 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const restoredPath = await this.decryptDatabaseFile(backupPath, targetPath);
|
const restoredPath = await this.decryptDatabaseFile(
|
||||||
|
|
||||||
databaseLogger.info("Database restored from encrypted backup", {
|
|
||||||
operation: "database_restore",
|
|
||||||
backupPath,
|
backupPath,
|
||||||
restoredPath,
|
targetPath,
|
||||||
});
|
);
|
||||||
|
|
||||||
return restoredPath;
|
return restoredPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -425,10 +373,6 @@ class DatabaseFileEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up temporary files
|
|
||||||
*/
|
|
||||||
static cleanupTempFiles(basePath: string): void {
|
static cleanupTempFiles(basePath: string): void {
|
||||||
try {
|
try {
|
||||||
const tempFiles = [
|
const tempFiles = [
|
||||||
|
|||||||
@@ -32,12 +32,11 @@ export class DatabaseMigration {
|
|||||||
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
|
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否需要迁移以及迁移状态
|
|
||||||
*/
|
|
||||||
checkMigrationStatus(): MigrationStatus {
|
checkMigrationStatus(): MigrationStatus {
|
||||||
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
|
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
|
||||||
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath);
|
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(
|
||||||
|
this.encryptedDbPath,
|
||||||
|
);
|
||||||
|
|
||||||
let unencryptedDbSize = 0;
|
let unencryptedDbSize = 0;
|
||||||
if (hasUnencryptedDb) {
|
if (hasUnencryptedDb) {
|
||||||
@@ -51,24 +50,21 @@ export class DatabaseMigration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定迁移状态
|
|
||||||
let needsMigration = false;
|
let needsMigration = false;
|
||||||
let reason = "";
|
let reason = "";
|
||||||
|
|
||||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||||
// 两个都存在:可能是之前迁移失败或中断
|
|
||||||
needsMigration = false;
|
needsMigration = false;
|
||||||
reason = "Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
reason =
|
||||||
|
"Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
||||||
} else if (hasEncryptedDb && !hasUnencryptedDb) {
|
} else if (hasEncryptedDb && !hasUnencryptedDb) {
|
||||||
// 只有加密数据库:无需迁移
|
|
||||||
needsMigration = false;
|
needsMigration = false;
|
||||||
reason = "Only encrypted database exists. No migration needed.";
|
reason = "Only encrypted database exists. No migration needed.";
|
||||||
} else if (!hasEncryptedDb && hasUnencryptedDb) {
|
} else if (!hasEncryptedDb && hasUnencryptedDb) {
|
||||||
// 只有未加密数据库:需要迁移
|
|
||||||
needsMigration = true;
|
needsMigration = true;
|
||||||
reason = "Unencrypted database found. Migration to encrypted format required.";
|
reason =
|
||||||
|
"Unencrypted database found. Migration to encrypted format required.";
|
||||||
} else {
|
} else {
|
||||||
// 都不存在:全新安装
|
|
||||||
needsMigration = false;
|
needsMigration = false;
|
||||||
reason = "No existing database found. This is a fresh installation.";
|
reason = "No existing database found. This is a fresh installation.";
|
||||||
}
|
}
|
||||||
@@ -82,36 +78,22 @@ export class DatabaseMigration {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建未加密数据库的安全备份
|
|
||||||
*/
|
|
||||||
private createBackup(): string {
|
private createBackup(): string {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
|
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Creating migration backup", {
|
|
||||||
operation: "migration_backup_create",
|
|
||||||
source: this.unencryptedDbPath,
|
|
||||||
backup: backupPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.copyFileSync(this.unencryptedDbPath, backupPath);
|
fs.copyFileSync(this.unencryptedDbPath, backupPath);
|
||||||
|
|
||||||
// 验证备份完整性
|
|
||||||
const originalSize = fs.statSync(this.unencryptedDbPath).size;
|
const originalSize = fs.statSync(this.unencryptedDbPath).size;
|
||||||
const backupSize = fs.statSync(backupPath).size;
|
const backupSize = fs.statSync(backupPath).size;
|
||||||
|
|
||||||
if (originalSize !== backupSize) {
|
if (originalSize !== backupSize) {
|
||||||
throw new Error(`Backup size mismatch: original=${originalSize}, backup=${backupSize}`);
|
throw new Error(
|
||||||
|
`Backup size mismatch: original=${originalSize}, backup=${backupSize}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.success("Migration backup created successfully", {
|
|
||||||
operation: "migration_backup_created",
|
|
||||||
backupPath,
|
|
||||||
fileSize: backupSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
return backupPath;
|
return backupPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to create migration backup", error, {
|
databaseLogger.error("Failed to create migration backup", error, {
|
||||||
@@ -119,79 +101,81 @@ export class DatabaseMigration {
|
|||||||
source: this.unencryptedDbPath,
|
source: this.unencryptedDbPath,
|
||||||
backup: backupPath,
|
backup: backupPath,
|
||||||
});
|
});
|
||||||
throw new Error(`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
throw new Error(
|
||||||
|
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async verifyMigration(
|
||||||
* 验证数据库迁移的完整性
|
originalDb: Database.Database,
|
||||||
*/
|
memoryDb: Database.Database,
|
||||||
private async verifyMigration(originalDb: Database.Database, memoryDb: Database.Database): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Verifying migration integrity", {
|
|
||||||
operation: "migration_verify_start",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 临时禁用外键约束以进行验证查询
|
|
||||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||||
|
|
||||||
// 获取原数据库的表列表
|
|
||||||
const originalTables = originalDb
|
const originalTables = originalDb
|
||||||
.prepare(`
|
.prepare(
|
||||||
|
`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`)
|
`,
|
||||||
|
)
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
|
|
||||||
// 获取内存数据库的表列表
|
|
||||||
const memoryTables = memoryDb
|
const memoryTables = memoryDb
|
||||||
.prepare(`
|
.prepare(
|
||||||
|
`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`)
|
`,
|
||||||
|
)
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
|
|
||||||
// 检查表数量是否一致
|
|
||||||
if (originalTables.length !== memoryTables.length) {
|
if (originalTables.length !== memoryTables.length) {
|
||||||
databaseLogger.error("Table count mismatch during migration verification", null, {
|
databaseLogger.error(
|
||||||
|
"Table count mismatch during migration verification",
|
||||||
|
null,
|
||||||
|
{
|
||||||
operation: "migration_verify_failed",
|
operation: "migration_verify_failed",
|
||||||
originalCount: originalTables.length,
|
originalCount: originalTables.length,
|
||||||
memoryCount: memoryTables.length,
|
memoryCount: memoryTables.length,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalOriginalRows = 0;
|
let totalOriginalRows = 0;
|
||||||
let totalMemoryRows = 0;
|
let totalMemoryRows = 0;
|
||||||
|
|
||||||
// 逐表验证行数
|
|
||||||
for (const table of originalTables) {
|
for (const table of originalTables) {
|
||||||
const originalCount = originalDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
const originalCount = originalDb
|
||||||
const memoryCount = memoryDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
.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;
|
totalOriginalRows += originalCount.count;
|
||||||
totalMemoryRows += memoryCount.count;
|
totalMemoryRows += memoryCount.count;
|
||||||
|
|
||||||
if (originalCount.count !== memoryCount.count) {
|
if (originalCount.count !== memoryCount.count) {
|
||||||
databaseLogger.error("Row count mismatch for table during migration verification", null, {
|
databaseLogger.error(
|
||||||
|
"Row count mismatch for table during migration verification",
|
||||||
|
null,
|
||||||
|
{
|
||||||
operation: "migration_verify_table_failed",
|
operation: "migration_verify_table_failed",
|
||||||
table: table.name,
|
table: table.name,
|
||||||
originalRows: originalCount.count,
|
originalRows: originalCount.count,
|
||||||
memoryRows: memoryCount.count,
|
memoryRows: memoryCount.count,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.success("Migration integrity verification completed", {
|
|
||||||
operation: "migration_verify_success",
|
|
||||||
tables: originalTables.length,
|
|
||||||
totalRows: totalOriginalRows,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 重新启用外键约束
|
|
||||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -203,9 +187,6 @@ export class DatabaseMigration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行数据库迁移
|
|
||||||
*/
|
|
||||||
async migrateDatabase(): Promise<MigrationResult> {
|
async migrateDatabase(): Promise<MigrationResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let backupPath: string | undefined;
|
let backupPath: string | undefined;
|
||||||
@@ -213,49 +194,31 @@ export class DatabaseMigration {
|
|||||||
let migratedRows = 0;
|
let migratedRows = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Starting database migration from unencrypted to encrypted format", {
|
|
||||||
operation: "migration_start",
|
|
||||||
source: this.unencryptedDbPath,
|
|
||||||
target: this.encryptedDbPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. 创建安全备份
|
|
||||||
backupPath = this.createBackup();
|
backupPath = this.createBackup();
|
||||||
|
|
||||||
// 2. 打开原数据库(只读)
|
const originalDb = new Database(this.unencryptedDbPath, {
|
||||||
const originalDb = new Database(this.unencryptedDbPath, { readonly: true });
|
readonly: true,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. 创建内存数据库
|
|
||||||
const memoryDb = new Database(":memory:");
|
const memoryDb = new Database(":memory:");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 4. 获取所有表结构
|
|
||||||
const tables = originalDb
|
const tables = originalDb
|
||||||
.prepare(`
|
.prepare(
|
||||||
|
`
|
||||||
SELECT name, sql FROM sqlite_master
|
SELECT name, sql FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
`)
|
`,
|
||||||
|
)
|
||||||
.all() as { name: string; sql: string }[];
|
.all() as { name: string; sql: string }[];
|
||||||
|
|
||||||
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) {
|
for (const table of tables) {
|
||||||
memoryDb.exec(table.sql);
|
memoryDb.exec(table.sql);
|
||||||
migratedTables++;
|
migratedTables++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 禁用外键约束以避免插入顺序问题
|
|
||||||
databaseLogger.info("Disabling foreign key constraints for migration", {
|
|
||||||
operation: "migration_disable_fk",
|
|
||||||
});
|
|
||||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||||
|
|
||||||
// 7. 复制每个表的数据
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||||
|
|
||||||
@@ -263,66 +226,64 @@ export class DatabaseMigration {
|
|||||||
const columns = Object.keys(rows[0]);
|
const columns = Object.keys(rows[0]);
|
||||||
const placeholders = columns.map(() => "?").join(", ");
|
const placeholders = columns.map(() => "?").join(", ");
|
||||||
const insertStmt = memoryDb.prepare(
|
const insertStmt = memoryDb.prepare(
|
||||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`
|
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 使用事务批量插入
|
const insertTransaction = memoryDb.transaction(
|
||||||
const insertTransaction = memoryDb.transaction((dataRows: any[]) => {
|
(dataRows: any[]) => {
|
||||||
for (const row of dataRows) {
|
for (const row of dataRows) {
|
||||||
const values = columns.map((col) => row[col]);
|
const values = columns.map((col) => row[col]);
|
||||||
insertStmt.run(values);
|
insertStmt.run(values);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
insertTransaction(rows);
|
insertTransaction(rows);
|
||||||
migratedRows += rows.length;
|
migratedRows += rows.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 重新启用外键约束
|
|
||||||
databaseLogger.info("Re-enabling foreign key constraints after migration", {
|
|
||||||
operation: "migration_enable_fk",
|
|
||||||
});
|
|
||||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
// 验证外键约束现在是否正常
|
const fkCheckResult = memoryDb
|
||||||
const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all();
|
.prepare("PRAGMA foreign_key_check")
|
||||||
|
.all();
|
||||||
if (fkCheckResult.length > 0) {
|
if (fkCheckResult.length > 0) {
|
||||||
databaseLogger.error("Foreign key constraints violations detected after migration", null, {
|
databaseLogger.error(
|
||||||
|
"Foreign key constraints violations detected after migration",
|
||||||
|
null,
|
||||||
|
{
|
||||||
operation: "migration_fk_check_failed",
|
operation: "migration_fk_check_failed",
|
||||||
violations: fkCheckResult,
|
violations: fkCheckResult,
|
||||||
});
|
},
|
||||||
throw new Error(`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`);
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.success("Foreign key constraints verification passed", {
|
const verificationPassed = await this.verifyMigration(
|
||||||
operation: "migration_fk_check_success",
|
originalDb,
|
||||||
});
|
memoryDb,
|
||||||
|
);
|
||||||
// 9. 验证迁移完整性
|
|
||||||
const verificationPassed = await this.verifyMigration(originalDb, memoryDb);
|
|
||||||
if (!verificationPassed) {
|
if (!verificationPassed) {
|
||||||
throw new Error("Migration integrity verification failed");
|
throw new Error("Migration integrity verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 导出内存数据库到缓冲区
|
|
||||||
const buffer = memoryDb.serialize();
|
const buffer = memoryDb.serialize();
|
||||||
|
|
||||||
// 11. 创建加密数据库文件
|
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||||
databaseLogger.info("Creating encrypted database file", {
|
buffer,
|
||||||
operation: "migration_encrypt_start",
|
this.encryptedDbPath,
|
||||||
bufferSize: buffer.length,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, this.encryptedDbPath);
|
if (
|
||||||
|
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
|
||||||
// 12. 验证加密文件
|
) {
|
||||||
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)) {
|
|
||||||
throw new Error("Encrypted database file verification failed");
|
throw new Error("Encrypted database file verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 13. 清理:重命名原文件而不是删除
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
|
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
|
||||||
|
|
||||||
fs.renameSync(this.unencryptedDbPath, migratedPath);
|
fs.renameSync(this.unencryptedDbPath, migratedPath);
|
||||||
@@ -344,15 +305,13 @@ export class DatabaseMigration {
|
|||||||
backupPath,
|
backupPath,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
// 确保数据库连接关闭
|
|
||||||
originalDb.close();
|
originalDb.close();
|
||||||
memoryDb.close();
|
memoryDb.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
||||||
databaseLogger.error("Database migration failed", error, {
|
databaseLogger.error("Database migration failed", error, {
|
||||||
operation: "migration_failed",
|
operation: "migration_failed",
|
||||||
@@ -373,34 +332,33 @@ export class DatabaseMigration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理旧的备份文件(保留最近3个)
|
|
||||||
*/
|
|
||||||
cleanupOldBackups(): void {
|
cleanupOldBackups(): void {
|
||||||
try {
|
try {
|
||||||
const backupPattern = /\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
const backupPattern =
|
||||||
const migratedPattern = /\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
/\.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 files = fs.readdirSync(this.dataDir);
|
const files = fs.readdirSync(this.dataDir);
|
||||||
|
|
||||||
// 查找备份文件和已迁移文件
|
const backupFiles = files
|
||||||
const backupFiles = files.filter(f => backupPattern.test(f))
|
.filter((f) => backupPattern.test(f))
|
||||||
.map(f => ({
|
.map((f) => ({
|
||||||
name: f,
|
name: f,
|
||||||
path: path.join(this.dataDir, f),
|
path: path.join(this.dataDir, f),
|
||||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||||
|
|
||||||
const migratedFiles = files.filter(f => migratedPattern.test(f))
|
const migratedFiles = files
|
||||||
.map(f => ({
|
.filter((f) => migratedPattern.test(f))
|
||||||
|
.map((f) => ({
|
||||||
name: f,
|
name: f,
|
||||||
path: path.join(this.dataDir, f),
|
path: path.join(this.dataDir, f),
|
||||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||||
|
|
||||||
// 保留最近3个备份文件
|
|
||||||
const backupsToDelete = backupFiles.slice(3);
|
const backupsToDelete = backupFiles.slice(3);
|
||||||
const migratedToDelete = migratedFiles.slice(3);
|
const migratedToDelete = migratedFiles.slice(3);
|
||||||
|
|
||||||
@@ -415,17 +373,6 @@ export class DatabaseMigration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("Migration cleanup failed", {
|
databaseLogger.warn("Migration cleanup failed", {
|
||||||
operation: "migration_cleanup_error",
|
operation: "migration_cleanup_error",
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* Database Save Trigger - 自动触发内存数据库保存到磁盘
|
|
||||||
* 确保数据修改后能持久化保存
|
|
||||||
*/
|
|
||||||
export class DatabaseSaveTrigger {
|
export class DatabaseSaveTrigger {
|
||||||
private static saveFunction: (() => Promise<void>) | null = null;
|
private static saveFunction: (() => Promise<void>) | null = null;
|
||||||
private static isInitialized = false;
|
private static isInitialized = false;
|
||||||
private static pendingSave = false;
|
private static pendingSave = false;
|
||||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化保存触发器
|
|
||||||
*/
|
|
||||||
static initialize(saveFunction: () => Promise<void>): void {
|
static initialize(saveFunction: () => Promise<void>): void {
|
||||||
this.saveFunction = saveFunction;
|
this.saveFunction = saveFunction;
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|
||||||
databaseLogger.info("Database save trigger initialized", {
|
|
||||||
operation: "db_save_trigger_init",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static async triggerSave(
|
||||||
* 触发数据库保存 - 防抖处理,避免频繁保存
|
reason: string = "data_modification",
|
||||||
*/
|
): Promise<void> {
|
||||||
static async triggerSave(reason: string = "data_modification"): Promise<void> {
|
|
||||||
if (!this.isInitialized || !this.saveFunction) {
|
if (!this.isInitialized || !this.saveFunction) {
|
||||||
databaseLogger.warn("Database save trigger not initialized", {
|
databaseLogger.warn("Database save trigger not initialized", {
|
||||||
operation: "db_save_trigger_not_init",
|
operation: "db_save_trigger_not_init",
|
||||||
@@ -34,12 +22,10 @@ export class DatabaseSaveTrigger {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除之前的定时器
|
|
||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
|
||||||
this.saveTimeout = setTimeout(async () => {
|
this.saveTimeout = setTimeout(async () => {
|
||||||
if (this.pendingSave) {
|
if (this.pendingSave) {
|
||||||
return;
|
return;
|
||||||
@@ -58,22 +44,21 @@ export class DatabaseSaveTrigger {
|
|||||||
} finally {
|
} finally {
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
}
|
}
|
||||||
}, 2000); // 2秒防抖
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 立即保存 - 用于关键操作
|
|
||||||
*/
|
|
||||||
static async forceSave(reason: string = "critical_operation"): Promise<void> {
|
static async forceSave(reason: string = "critical_operation"): Promise<void> {
|
||||||
if (!this.isInitialized || !this.saveFunction) {
|
if (!this.isInitialized || !this.saveFunction) {
|
||||||
databaseLogger.warn("Database save trigger not initialized for force save", {
|
databaseLogger.warn(
|
||||||
|
"Database save trigger not initialized for force save",
|
||||||
|
{
|
||||||
operation: "db_save_trigger_force_not_init",
|
operation: "db_save_trigger_force_not_init",
|
||||||
reason,
|
reason,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除防抖定时器
|
|
||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
@@ -92,26 +77,18 @@ export class DatabaseSaveTrigger {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.saveFunction();
|
await this.saveFunction();
|
||||||
|
|
||||||
databaseLogger.success("Database force save completed", {
|
|
||||||
operation: "db_save_trigger_force_success",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Database force save failed", error, {
|
databaseLogger.error("Database force save failed", error, {
|
||||||
operation: "db_save_trigger_force_failed",
|
operation: "db_save_trigger_force_failed",
|
||||||
reason,
|
reason,
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
throw error; // 重新抛出错误,因为这是强制保存
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取保存状态
|
|
||||||
*/
|
|
||||||
static getStatus(): {
|
static getStatus(): {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
pendingSave: boolean;
|
pendingSave: boolean;
|
||||||
@@ -124,9 +101,6 @@ export class DatabaseSaveTrigger {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理资源
|
|
||||||
*/
|
|
||||||
static cleanup(): void {
|
static cleanup(): void {
|
||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
|
|||||||
@@ -5,40 +5,46 @@ interface EncryptedData {
|
|||||||
iv: string;
|
iv: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
salt: string;
|
salt: string;
|
||||||
recordId: string; // Store the recordId used for encryption context
|
recordId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
class FieldCrypto {
|
||||||
private static readonly ALGORITHM = "aes-256-gcm";
|
private static readonly ALGORITHM = "aes-256-gcm";
|
||||||
private static readonly KEY_LENGTH = 32;
|
private static readonly KEY_LENGTH = 32;
|
||||||
private static readonly IV_LENGTH = 16;
|
private static readonly IV_LENGTH = 16;
|
||||||
private static readonly SALT_LENGTH = 32;
|
private static readonly SALT_LENGTH = 32;
|
||||||
|
|
||||||
// Fields requiring encryption - simple mapping, no complex logic
|
|
||||||
private static readonly ENCRYPTED_FIELDS = {
|
private static readonly ENCRYPTED_FIELDS = {
|
||||||
users: new Set(["password_hash", "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier"]),
|
users: new Set([
|
||||||
|
"password_hash",
|
||||||
|
"client_secret",
|
||||||
|
"totp_secret",
|
||||||
|
"totp_backup_codes",
|
||||||
|
"oidc_identifier",
|
||||||
|
]),
|
||||||
ssh_data: new Set(["password", "key", "keyPassword"]),
|
ssh_data: new Set(["password", "key", "keyPassword"]),
|
||||||
ssh_credentials: new Set(["password", "privateKey", "keyPassword", "key", "publicKey"]),
|
ssh_credentials: new Set([
|
||||||
|
"password",
|
||||||
|
"privateKey",
|
||||||
|
"keyPassword",
|
||||||
|
"key",
|
||||||
|
"publicKey",
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
static encryptField(
|
||||||
* Encrypt field - no special cases
|
plaintext: string,
|
||||||
*/
|
masterKey: Buffer,
|
||||||
static encryptField(plaintext: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
recordId: string,
|
||||||
|
fieldName: string,
|
||||||
|
): string {
|
||||||
if (!plaintext) return "";
|
if (!plaintext) return "";
|
||||||
|
|
||||||
const salt = crypto.randomBytes(this.SALT_LENGTH);
|
const salt = crypto.randomBytes(this.SALT_LENGTH);
|
||||||
const context = `${recordId}:${fieldName}`;
|
const context = `${recordId}:${fieldName}`;
|
||||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
const fieldKey = Buffer.from(
|
||||||
|
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
const iv = crypto.randomBytes(this.IV_LENGTH);
|
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
||||||
@@ -52,29 +58,38 @@ class FieldCrypto {
|
|||||||
iv: iv.toString("hex"),
|
iv: iv.toString("hex"),
|
||||||
tag: tag.toString("hex"),
|
tag: tag.toString("hex"),
|
||||||
salt: salt.toString("hex"),
|
salt: salt.toString("hex"),
|
||||||
recordId: recordId, // Store recordId for consistent decryption context
|
recordId: recordId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return JSON.stringify(encryptedData);
|
return JSON.stringify(encryptedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static decryptField(
|
||||||
* Decrypt field - either succeeds or fails, no third option
|
encryptedValue: string,
|
||||||
*/
|
masterKey: Buffer,
|
||||||
static decryptField(encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
recordId: string,
|
||||||
|
fieldName: string,
|
||||||
|
): string {
|
||||||
if (!encryptedValue) return "";
|
if (!encryptedValue) return "";
|
||||||
|
|
||||||
const encrypted: EncryptedData = JSON.parse(encryptedValue);
|
const encrypted: EncryptedData = JSON.parse(encryptedValue);
|
||||||
const salt = Buffer.from(encrypted.salt, "hex");
|
const salt = Buffer.from(encrypted.salt, "hex");
|
||||||
|
|
||||||
// Use ONLY the recordId that was stored during encryption
|
|
||||||
if (!encrypted.recordId) {
|
if (!encrypted.recordId) {
|
||||||
throw new Error(`Encrypted field missing recordId context - data corruption or legacy format not supported`);
|
throw new Error(
|
||||||
|
`Encrypted field missing recordId context - data corruption or legacy format not supported`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const context = `${encrypted.recordId}:${fieldName}`;
|
const context = `${encrypted.recordId}:${fieldName}`;
|
||||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
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;
|
const decipher = crypto.createDecipheriv(
|
||||||
|
this.ALGORITHM,
|
||||||
|
fieldKey,
|
||||||
|
Buffer.from(encrypted.iv, "hex"),
|
||||||
|
) as any;
|
||||||
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
||||||
@@ -83,11 +98,9 @@ class FieldCrypto {
|
|||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if field needs encryption - simple table lookup, no complex logic
|
|
||||||
*/
|
|
||||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||||
const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
const fields =
|
||||||
|
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||||
return fields ? fields.has(fieldName) : false;
|
return fields ? fields.has(fieldName) : false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,47 @@
|
|||||||
import { FieldCrypto } from "./field-crypto.js";
|
import { FieldCrypto } from "./field-crypto.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* 延迟字段加密 - 处理从明文到加密的平滑迁移
|
|
||||||
* 用于在用户登录时将明文敏感数据逐步加密
|
|
||||||
*/
|
|
||||||
export class LazyFieldEncryption {
|
export class LazyFieldEncryption {
|
||||||
/**
|
|
||||||
* 检测字段是否为明文(未加密)
|
|
||||||
*/
|
|
||||||
static isPlaintextField(value: string): boolean {
|
static isPlaintextField(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
// 如果能解析为JSON且包含加密数据结构,则认为已加密
|
if (
|
||||||
if (parsed && typeof parsed === 'object' &&
|
parsed &&
|
||||||
parsed.data && parsed.iv && parsed.tag && parsed.salt && parsed.recordId) {
|
typeof parsed === "object" &&
|
||||||
return false; // 已加密
|
parsed.data &&
|
||||||
|
parsed.iv &&
|
||||||
|
parsed.tag &&
|
||||||
|
parsed.salt &&
|
||||||
|
parsed.recordId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
// JSON格式但不是加密结构,视为明文
|
|
||||||
return true;
|
return true;
|
||||||
} catch (jsonError) {
|
} catch (jsonError) {
|
||||||
// 无法解析为JSON,视为明文
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 安全获取字段值 - 自动处理明文和加密数据
|
|
||||||
* 如果是明文,直接返回;如果已加密,则解密
|
|
||||||
*/
|
|
||||||
static safeGetFieldValue(
|
static safeGetFieldValue(
|
||||||
fieldValue: string,
|
fieldValue: string,
|
||||||
userKEK: Buffer,
|
userKEK: Buffer,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
fieldName: string
|
fieldName: string,
|
||||||
): string {
|
): string {
|
||||||
if (!fieldValue) return "";
|
if (!fieldValue) return "";
|
||||||
|
|
||||||
if (this.isPlaintextField(fieldValue)) {
|
if (this.isPlaintextField(fieldValue)) {
|
||||||
// 明文数据,直接返回
|
|
||||||
return fieldValue;
|
return fieldValue;
|
||||||
} else {
|
} else {
|
||||||
// 加密数据,需要解密
|
|
||||||
try {
|
try {
|
||||||
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
const decrypted = FieldCrypto.decryptField(
|
||||||
|
fieldValue,
|
||||||
|
userKEK,
|
||||||
|
recordId,
|
||||||
|
fieldName,
|
||||||
|
);
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to decrypt field", error, {
|
databaseLogger.error("Failed to decrypt field", error, {
|
||||||
@@ -59,31 +55,24 @@ export class LazyFieldEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 迁移明文字段到加密状态
|
|
||||||
* 返回加密后的值,如果已经加密则返回原值
|
|
||||||
*/
|
|
||||||
static migrateFieldToEncrypted(
|
static migrateFieldToEncrypted(
|
||||||
fieldValue: string,
|
fieldValue: string,
|
||||||
userKEK: Buffer,
|
userKEK: Buffer,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
fieldName: string
|
fieldName: string,
|
||||||
): { encrypted: string; wasPlaintext: boolean } {
|
): { encrypted: string; wasPlaintext: boolean } {
|
||||||
if (!fieldValue) {
|
if (!fieldValue) {
|
||||||
return { encrypted: "", wasPlaintext: false };
|
return { encrypted: "", wasPlaintext: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isPlaintextField(fieldValue)) {
|
if (this.isPlaintextField(fieldValue)) {
|
||||||
// 明文数据,需要加密
|
|
||||||
try {
|
try {
|
||||||
const encrypted = FieldCrypto.encryptField(fieldValue, userKEK, recordId, fieldName);
|
const encrypted = FieldCrypto.encryptField(
|
||||||
|
fieldValue,
|
||||||
databaseLogger.info("Field migrated from plaintext to encrypted", {
|
userKEK,
|
||||||
operation: "lazy_encryption_migrate_success",
|
|
||||||
recordId,
|
recordId,
|
||||||
fieldName,
|
fieldName,
|
||||||
plaintextLength: fieldValue.length,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return { encrypted, wasPlaintext: true };
|
return { encrypted, wasPlaintext: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,23 +85,19 @@ export class LazyFieldEncryption {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 已经加密,无需处理
|
|
||||||
return { encrypted: fieldValue, wasPlaintext: false };
|
return { encrypted: fieldValue, wasPlaintext: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量迁移记录中的敏感字段
|
|
||||||
*/
|
|
||||||
static migrateRecordSensitiveFields(
|
static migrateRecordSensitiveFields(
|
||||||
record: any,
|
record: any,
|
||||||
sensitiveFields: string[],
|
sensitiveFields: string[],
|
||||||
userKEK: Buffer,
|
userKEK: Buffer,
|
||||||
recordId: string
|
recordId: string,
|
||||||
): {
|
): {
|
||||||
updatedRecord: any;
|
updatedRecord: any;
|
||||||
migratedFields: string[];
|
migratedFields: string[];
|
||||||
needsUpdate: boolean
|
needsUpdate: boolean;
|
||||||
} {
|
} {
|
||||||
const updatedRecord = { ...record };
|
const updatedRecord = { ...record };
|
||||||
const migratedFields: string[] = [];
|
const migratedFields: string[] = [];
|
||||||
@@ -127,7 +112,7 @@ export class LazyFieldEncryption {
|
|||||||
fieldValue,
|
fieldValue,
|
||||||
userKEK,
|
userKEK,
|
||||||
recordId,
|
recordId,
|
||||||
fieldName
|
fieldName,
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedRecord[fieldName] = encrypted;
|
updatedRecord[fieldName] = encrypted;
|
||||||
@@ -139,55 +124,48 @@ export class LazyFieldEncryption {
|
|||||||
recordId,
|
recordId,
|
||||||
fieldName,
|
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 };
|
return { updatedRecord, migratedFields, needsUpdate };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取敏感字段列表 - 定义哪些字段需要延迟加密
|
|
||||||
*/
|
|
||||||
static getSensitiveFieldsForTable(tableName: string): string[] {
|
static getSensitiveFieldsForTable(tableName: string): string[] {
|
||||||
const sensitiveFieldsMap: Record<string, string[]> = {
|
const sensitiveFieldsMap: Record<string, string[]> = {
|
||||||
'ssh_data': ['password', 'key', 'key_password'],
|
ssh_data: ["password", "key", "key_password"],
|
||||||
'ssh_credentials': ['password', 'key', 'key_password', 'private_key'],
|
ssh_credentials: ["password", "key", "key_password", "private_key"],
|
||||||
'users': ['totp_secret', 'totp_backup_codes'],
|
users: ["totp_secret", "totp_backup_codes"],
|
||||||
};
|
};
|
||||||
|
|
||||||
return sensitiveFieldsMap[tableName] || [];
|
return sensitiveFieldsMap[tableName] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查用户是否有需要迁移的明文数据
|
|
||||||
*/
|
|
||||||
static async checkUserNeedsMigration(
|
static async checkUserNeedsMigration(
|
||||||
userId: string,
|
userId: string,
|
||||||
userKEK: Buffer,
|
userKEK: Buffer,
|
||||||
db: any
|
db: any,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
needsMigration: boolean;
|
needsMigration: boolean;
|
||||||
plaintextFields: Array<{ table: string; recordId: string; fields: string[] }>;
|
plaintextFields: Array<{
|
||||||
|
table: string;
|
||||||
|
recordId: string;
|
||||||
|
fields: string[];
|
||||||
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const plaintextFields: Array<{ table: string; recordId: string; fields: string[] }> = [];
|
const plaintextFields: Array<{
|
||||||
|
table: string;
|
||||||
|
recordId: string;
|
||||||
|
fields: string[];
|
||||||
|
}> = [];
|
||||||
let needsMigration = false;
|
let needsMigration = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查 ssh_data 表
|
const sshHosts = db
|
||||||
const sshHosts = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
|
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||||
|
.all(userId);
|
||||||
for (const host of sshHosts) {
|
for (const host of sshHosts) {
|
||||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_data');
|
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
|
||||||
const hostPlaintextFields: string[] = [];
|
const hostPlaintextFields: string[] = [];
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
for (const field of sensitiveFields) {
|
||||||
@@ -199,17 +177,19 @@ export class LazyFieldEncryption {
|
|||||||
|
|
||||||
if (hostPlaintextFields.length > 0) {
|
if (hostPlaintextFields.length > 0) {
|
||||||
plaintextFields.push({
|
plaintextFields.push({
|
||||||
table: 'ssh_data',
|
table: "ssh_data",
|
||||||
recordId: host.id.toString(),
|
recordId: host.id.toString(),
|
||||||
fields: hostPlaintextFields,
|
fields: hostPlaintextFields,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 ssh_credentials 表
|
const sshCredentials = db
|
||||||
const sshCredentials = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||||
|
.all(userId);
|
||||||
for (const credential of sshCredentials) {
|
for (const credential of sshCredentials) {
|
||||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_credentials');
|
const sensitiveFields =
|
||||||
|
this.getSensitiveFieldsForTable("ssh_credentials");
|
||||||
const credentialPlaintextFields: string[] = [];
|
const credentialPlaintextFields: string[] = [];
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
for (const field of sensitiveFields) {
|
||||||
@@ -221,17 +201,16 @@ export class LazyFieldEncryption {
|
|||||||
|
|
||||||
if (credentialPlaintextFields.length > 0) {
|
if (credentialPlaintextFields.length > 0) {
|
||||||
plaintextFields.push({
|
plaintextFields.push({
|
||||||
table: 'ssh_credentials',
|
table: "ssh_credentials",
|
||||||
recordId: credential.id.toString(),
|
recordId: credential.id.toString(),
|
||||||
fields: credentialPlaintextFields,
|
fields: credentialPlaintextFields,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 users 表中的敏感字段
|
|
||||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||||
if (user) {
|
if (user) {
|
||||||
const sensitiveFields = this.getSensitiveFieldsForTable('users');
|
const sensitiveFields = this.getSensitiveFieldsForTable("users");
|
||||||
const userPlaintextFields: string[] = [];
|
const userPlaintextFields: string[] = [];
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
for (const field of sensitiveFields) {
|
||||||
@@ -243,23 +222,14 @@ export class LazyFieldEncryption {
|
|||||||
|
|
||||||
if (userPlaintextFields.length > 0) {
|
if (userPlaintextFields.length > 0) {
|
||||||
plaintextFields.push({
|
plaintextFields.push({
|
||||||
table: 'users',
|
table: "users",
|
||||||
recordId: userId,
|
recordId: userId,
|
||||||
fields: userPlaintextFields,
|
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 };
|
return { needsMigration, plaintextFields };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to check user migration needs", error, {
|
databaseLogger.error("Failed to check user migration needs", error, {
|
||||||
operation: "lazy_encryption_user_check_failed",
|
operation: "lazy_encryption_user_check_failed",
|
||||||
|
|||||||
+49
-27
@@ -14,23 +14,35 @@ export interface LogContext {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sensitive fields that should be masked in logs
|
|
||||||
const SENSITIVE_FIELDS = [
|
const SENSITIVE_FIELDS = [
|
||||||
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
|
"password",
|
||||||
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
|
"passphrase",
|
||||||
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
|
"key",
|
||||||
|
"privateKey",
|
||||||
|
"publicKey",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"clientSecret",
|
||||||
|
"keyPassword",
|
||||||
|
"autostartPassword",
|
||||||
|
"autostartKey",
|
||||||
|
"autostartKeyPassword",
|
||||||
|
"credentialId",
|
||||||
|
"authToken",
|
||||||
|
"jwt",
|
||||||
|
"session",
|
||||||
|
"cookie",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fields that should be truncated if too long
|
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
|
||||||
const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request'];
|
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private serviceName: string;
|
private serviceName: string;
|
||||||
private serviceIcon: string;
|
private serviceIcon: string;
|
||||||
private serviceColor: string;
|
private serviceColor: string;
|
||||||
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
||||||
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
|
private readonly RATE_LIMIT_WINDOW = 60000;
|
||||||
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
|
private readonly RATE_LIMIT_MAX = 10;
|
||||||
|
|
||||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
this.serviceName = serviceName;
|
this.serviceName = serviceName;
|
||||||
@@ -45,23 +57,28 @@ class Logger {
|
|||||||
private sanitizeContext(context: LogContext): LogContext {
|
private sanitizeContext(context: LogContext): LogContext {
|
||||||
const sanitized = { ...context };
|
const sanitized = { ...context };
|
||||||
|
|
||||||
// Mask sensitive fields
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
for (const field of SENSITIVE_FIELDS) {
|
||||||
if (sanitized[field] !== undefined) {
|
if (sanitized[field] !== undefined) {
|
||||||
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
|
if (
|
||||||
sanitized[field] = '[MASKED]';
|
typeof sanitized[field] === "string" &&
|
||||||
} else if (typeof sanitized[field] === 'boolean') {
|
sanitized[field].length > 0
|
||||||
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
|
) {
|
||||||
|
sanitized[field] = "[MASKED]";
|
||||||
|
} else if (typeof sanitized[field] === "boolean") {
|
||||||
|
sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]";
|
||||||
} else {
|
} else {
|
||||||
sanitized[field] = '[MASKED]';
|
sanitized[field] = "[MASKED]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate long fields
|
|
||||||
for (const field of TRUNCATE_FIELDS) {
|
for (const field of TRUNCATE_FIELDS) {
|
||||||
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
|
if (
|
||||||
sanitized[field] = sanitized[field].substring(0, 100) + '...';
|
sanitized[field] &&
|
||||||
|
typeof sanitized[field] === "string" &&
|
||||||
|
sanitized[field].length > 100
|
||||||
|
) {
|
||||||
|
sanitized[field] = sanitized[field].substring(0, 100) + "...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +99,20 @@ class Logger {
|
|||||||
if (context) {
|
if (context) {
|
||||||
const sanitizedContext = this.sanitizeContext(context);
|
const sanitizedContext = this.sanitizeContext(context);
|
||||||
const contextParts = [];
|
const contextParts = [];
|
||||||
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
|
if (sanitizedContext.operation)
|
||||||
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
|
contextParts.push(`op:${sanitizedContext.operation}`);
|
||||||
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
|
if (sanitizedContext.userId)
|
||||||
if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
contextParts.push(`user:${sanitizedContext.userId}`);
|
||||||
if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`);
|
if (sanitizedContext.hostId)
|
||||||
if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`);
|
contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||||
if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
if (sanitizedContext.tunnelName)
|
||||||
|
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||||
|
if (sanitizedContext.sessionId)
|
||||||
|
contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||||
|
if (sanitizedContext.requestId)
|
||||||
|
contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||||
|
if (sanitizedContext.duration)
|
||||||
|
contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||||
|
|
||||||
if (contextParts.length > 0) {
|
if (contextParts.length > 0) {
|
||||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||||
@@ -120,7 +144,6 @@ class Logger {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting for frequent messages
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const logKey = `${level}:${message}`;
|
const logKey = `${level}:${message}`;
|
||||||
const logInfo = this.logCounts.get(logKey);
|
const logInfo = this.logCounts.get(logKey);
|
||||||
@@ -129,10 +152,9 @@ class Logger {
|
|||||||
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
||||||
logInfo.count++;
|
logInfo.count++;
|
||||||
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
||||||
return false; // Rate limited
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset counter for new window
|
|
||||||
logInfo.count = 1;
|
logInfo.count = 1;
|
||||||
logInfo.lastLog = now;
|
logInfo.lastLog = now;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,94 @@
|
|||||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
|
||||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
class SimpleDBOps {
|
||||||
/**
|
|
||||||
* Insert encrypted record
|
|
||||||
*/
|
|
||||||
static async insert<T extends Record<string, any>>(
|
static async insert<T extends Record<string, any>>(
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
data: T,
|
data: T,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// Get user data key once and reuse throughout operation
|
|
||||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
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 tempId = data.id || `temp-${userId}-${Date.now()}`;
|
||||||
const dataWithTempId = { ...data, id: tempId };
|
const dataWithTempId = { ...data, id: tempId };
|
||||||
|
|
||||||
// Encrypt data using the locked key - recordId will be stored in encrypted fields
|
const encryptedData = DataCrypto.encryptRecord(
|
||||||
const encryptedData = DataCrypto.encryptRecord(tableName, dataWithTempId, userId, userDataKey);
|
tableName,
|
||||||
|
dataWithTempId,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
|
|
||||||
// Remove temp ID if it was generated, let database assign real ID
|
|
||||||
if (!data.id) {
|
if (!data.id) {
|
||||||
delete encryptedData.id;
|
delete encryptedData.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into database
|
const result = await getDb()
|
||||||
const result = await getDb().insert(table).values(encryptedData).returning();
|
.insert(table)
|
||||||
|
.values(encryptedData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Trigger database save after insert
|
|
||||||
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
|
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
|
||||||
|
|
||||||
// Decrypt return result using the same key - FieldCrypto will use stored recordId
|
|
||||||
const decryptedResult = DataCrypto.decryptRecord(
|
const decryptedResult = DataCrypto.decryptRecord(
|
||||||
tableName,
|
tableName,
|
||||||
result[0],
|
result[0],
|
||||||
userId,
|
userId,
|
||||||
userDataKey
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return decryptedResult as T;
|
return decryptedResult as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query multiple records
|
|
||||||
*/
|
|
||||||
static async select<T extends Record<string, any>>(
|
static async select<T extends Record<string, any>>(
|
||||||
query: any,
|
query: any,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
// Check if user data is unlocked - return empty array if locked
|
|
||||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
databaseLogger.debug("User data locked - returning empty results", {
|
|
||||||
operation: "select_data_locked",
|
|
||||||
userId,
|
|
||||||
tableName
|
|
||||||
});
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute query
|
|
||||||
const results = await query;
|
const results = await query;
|
||||||
|
|
||||||
// Decrypt results using locked key
|
|
||||||
const decryptedResults = DataCrypto.decryptRecords(
|
const decryptedResults = DataCrypto.decryptRecords(
|
||||||
tableName,
|
tableName,
|
||||||
results,
|
results,
|
||||||
userId,
|
userId,
|
||||||
userDataKey
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query single record
|
|
||||||
*/
|
|
||||||
static async selectOne<T extends Record<string, any>>(
|
static async selectOne<T extends Record<string, any>>(
|
||||||
query: any,
|
query: any,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
// Check if user data is unlocked - return undefined if locked
|
|
||||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
databaseLogger.debug("User data locked - returning undefined", {
|
|
||||||
operation: "selectOne_data_locked",
|
|
||||||
userId,
|
|
||||||
tableName
|
|
||||||
});
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute query
|
|
||||||
const result = await query;
|
const result = await query;
|
||||||
if (!result) return undefined;
|
if (!result) return undefined;
|
||||||
|
|
||||||
// Decrypt results using locked key
|
|
||||||
const decryptedResult = DataCrypto.decryptRecord(
|
const decryptedResult = DataCrypto.decryptRecord(
|
||||||
tableName,
|
tableName,
|
||||||
result,
|
result,
|
||||||
userId,
|
userId,
|
||||||
userDataKey
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return decryptedResult;
|
return decryptedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update record
|
|
||||||
*/
|
|
||||||
static async update<T extends Record<string, any>>(
|
static async update<T extends Record<string, any>>(
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
@@ -133,36 +96,33 @@ class SimpleDBOps {
|
|||||||
data: Partial<T>,
|
data: Partial<T>,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
// Get user data key once and reuse throughout operation
|
|
||||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||||
|
|
||||||
// Encrypt update data using the locked key
|
const encryptedData = DataCrypto.encryptRecord(
|
||||||
const encryptedData = DataCrypto.encryptRecord(tableName, data, userId, userDataKey);
|
tableName,
|
||||||
|
data,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
|
|
||||||
// Execute update
|
|
||||||
const result = await getDb()
|
const result = await getDb()
|
||||||
.update(table)
|
.update(table)
|
||||||
.set(encryptedData)
|
.set(encryptedData)
|
||||||
.where(where)
|
.where(where)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Trigger database save after update
|
|
||||||
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
||||||
|
|
||||||
// Decrypt return data using the same key
|
|
||||||
const decryptedResults = DataCrypto.decryptRecords(
|
const decryptedResults = DataCrypto.decryptRecords(
|
||||||
tableName,
|
tableName,
|
||||||
result,
|
result,
|
||||||
userId,
|
userId,
|
||||||
userDataKey
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return decryptedResults as T[];
|
return decryptedResults as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete record
|
|
||||||
*/
|
|
||||||
static async delete(
|
static async delete(
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
@@ -171,32 +131,23 @@ class SimpleDBOps {
|
|||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
const result = await getDb().delete(table).where(where).returning();
|
const result = await getDb().delete(table).where(where).returning();
|
||||||
|
|
||||||
// Trigger database save after delete
|
|
||||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check
|
|
||||||
*/
|
|
||||||
static async healthCheck(userId: string): Promise<boolean> {
|
static async healthCheck(userId: string): Promise<boolean> {
|
||||||
return DataCrypto.canUserAccessData(userId);
|
return DataCrypto.canUserAccessData(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user data is unlocked
|
|
||||||
*/
|
|
||||||
static isUserDataUnlocked(userId: string): boolean {
|
static isUserDataUnlocked(userId: string): boolean {
|
||||||
return DataCrypto.getUserDataKey(userId) !== null;
|
return DataCrypto.getUserDataKey(userId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static async selectEncrypted(
|
||||||
* Special method: return encrypted data (for auto-start scenarios)
|
query: any,
|
||||||
* No decryption, return data in encrypted state directly
|
tableName: TableName,
|
||||||
*/
|
): Promise<any[]> {
|
||||||
static async selectEncrypted(query: any, tableName: TableName): Promise<any[]> {
|
|
||||||
// Execute query directly, no decryption
|
|
||||||
const results = await query;
|
const results = await query;
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
// Import SSH2 using ES modules
|
|
||||||
import ssh2Pkg from "ssh2";
|
import ssh2Pkg from "ssh2";
|
||||||
const ssh2Utils = ssh2Pkg.utils;
|
const ssh2Utils = ssh2Pkg.utils;
|
||||||
|
|
||||||
// Simple fallback SSH key type detection
|
|
||||||
function detectKeyTypeFromContent(keyContent: string): string {
|
function detectKeyTypeFromContent(keyContent: string): string {
|
||||||
const content = keyContent.trim();
|
const content = keyContent.trim();
|
||||||
|
|
||||||
// Check for OpenSSH format headers
|
|
||||||
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||||
// Look for key type indicators in the content
|
|
||||||
if (
|
if (
|
||||||
content.includes("ssh-ed25519") ||
|
content.includes("ssh-ed25519") ||
|
||||||
content.includes("AAAAC3NzaC1lZDI1NTE5")
|
content.includes("AAAAC3NzaC1lZDI1NTE5")
|
||||||
@@ -28,14 +24,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
return "ecdsa-sha2-nistp521";
|
return "ecdsa-sha2-nistp521";
|
||||||
}
|
}
|
||||||
|
|
||||||
// For OpenSSH format, try to detect by analyzing the base64 content structure
|
|
||||||
try {
|
try {
|
||||||
const base64Content = content
|
const base64Content = content
|
||||||
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
|
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
|
||||||
.replace("-----END OPENSSH PRIVATE KEY-----", "")
|
.replace("-----END OPENSSH PRIVATE KEY-----", "")
|
||||||
.replace(/\s/g, "");
|
.replace(/\s/g, "");
|
||||||
|
|
||||||
// OpenSSH format starts with "openssh-key-v1" followed by key type
|
|
||||||
const decoded = Buffer.from(base64Content, "base64").toString("binary");
|
const decoded = Buffer.from(base64Content, "base64").toString("binary");
|
||||||
|
|
||||||
if (decoded.includes("ssh-rsa")) {
|
if (decoded.includes("ssh-rsa")) {
|
||||||
@@ -54,15 +48,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
return "ecdsa-sha2-nistp521";
|
return "ecdsa-sha2-nistp521";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to RSA for OpenSSH format if we can't detect specifically
|
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for traditional PEM headers
|
|
||||||
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
|
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
@@ -70,12 +61,10 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
return "ssh-dss";
|
return "ssh-dss";
|
||||||
}
|
}
|
||||||
if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
|
if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
|
||||||
return "ecdsa-sha2-nistp256"; // Default ECDSA type
|
return "ecdsa-sha2-nistp256";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for PKCS#8 format (modern format)
|
|
||||||
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
|
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
|
||||||
// Try to decode and analyze the DER structure for better detection
|
|
||||||
try {
|
try {
|
||||||
const base64Content = content
|
const base64Content = content
|
||||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
@@ -85,35 +74,23 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
const decoded = Buffer.from(base64Content, "base64");
|
const decoded = Buffer.from(base64Content, "base64");
|
||||||
const decodedString = decoded.toString("binary");
|
const decodedString = decoded.toString("binary");
|
||||||
|
|
||||||
// Check for algorithm identifiers in the DER structure
|
|
||||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||||
// RSA OID
|
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||||
// EC Private Key OID - this indicates ECDSA
|
|
||||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||||
// prime256v1 curve OID
|
|
||||||
return "ecdsa-sha2-nistp256";
|
return "ecdsa-sha2-nistp256";
|
||||||
}
|
}
|
||||||
return "ecdsa-sha2-nistp256"; // Default to P-256
|
return "ecdsa-sha2-nistp256";
|
||||||
} else if (decodedString.includes("1.3.101.112")) {
|
} else if (decodedString.includes("1.3.101.112")) {
|
||||||
// Ed25519 OID
|
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
// If decoding fails, fall back to length-based detection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try to detect key type from the content structure
|
|
||||||
// This is a fallback for PKCS#8 format keys
|
|
||||||
if (content.length < 800) {
|
if (content.length < 800) {
|
||||||
// Ed25519 keys are typically shorter
|
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
} else if (content.length > 1600) {
|
} else if (content.length > 1600) {
|
||||||
// RSA keys are typically longer
|
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
} else {
|
} else {
|
||||||
// ECDSA keys are typically medium length
|
|
||||||
return "ecdsa-sha2-nistp256";
|
return "ecdsa-sha2-nistp256";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,11 +98,9 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect public key type from public key content
|
|
||||||
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||||
const content = publicKeyContent.trim();
|
const content = publicKeyContent.trim();
|
||||||
|
|
||||||
// SSH public keys start with the key type
|
|
||||||
if (content.startsWith("ssh-rsa ")) {
|
if (content.startsWith("ssh-rsa ")) {
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
@@ -145,9 +120,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|||||||
return "ssh-dss";
|
return "ssh-dss";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for PEM format public keys
|
|
||||||
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
|
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
|
||||||
// Try to decode the base64 content to detect key type
|
|
||||||
try {
|
try {
|
||||||
const base64Content = content
|
const base64Content = content
|
||||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
@@ -157,26 +130,18 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|||||||
const decoded = Buffer.from(base64Content, "base64");
|
const decoded = Buffer.from(base64Content, "base64");
|
||||||
const decodedString = decoded.toString("binary");
|
const decodedString = decoded.toString("binary");
|
||||||
|
|
||||||
// Check for algorithm identifiers in the DER structure
|
|
||||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||||
// RSA OID
|
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||||
// EC Public Key OID - this indicates ECDSA
|
|
||||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||||
// prime256v1 curve OID
|
|
||||||
return "ecdsa-sha2-nistp256";
|
return "ecdsa-sha2-nistp256";
|
||||||
}
|
}
|
||||||
return "ecdsa-sha2-nistp256"; // Default to P-256
|
return "ecdsa-sha2-nistp256";
|
||||||
} else if (decodedString.includes("1.3.101.112")) {
|
} else if (decodedString.includes("1.3.101.112")) {
|
||||||
// Ed25519 OID
|
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
// If decoding fails, fall back to length-based detection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try to guess based on key length
|
|
||||||
if (content.length < 400) {
|
if (content.length < 400) {
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
} else if (content.length > 600) {
|
} else if (content.length > 600) {
|
||||||
@@ -190,7 +155,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for base64 encoded key data patterns
|
|
||||||
if (content.includes("AAAAB3NzaC1yc2E")) {
|
if (content.includes("AAAAB3NzaC1yc2E")) {
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
@@ -236,9 +200,6 @@ export interface KeyPairValidationResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SSH private key and extract public key and type information
|
|
||||||
*/
|
|
||||||
export function parseSSHKey(
|
export function parseSSHKey(
|
||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
@@ -248,28 +209,21 @@ export function parseSSHKey(
|
|||||||
let publicKey = "";
|
let publicKey = "";
|
||||||
let useSSH2 = false;
|
let useSSH2 = false;
|
||||||
|
|
||||||
// Try SSH2 first if available
|
|
||||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||||
try {
|
try {
|
||||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||||
|
|
||||||
if (!(parsedKey instanceof Error)) {
|
if (!(parsedKey instanceof Error)) {
|
||||||
// Extract key type
|
|
||||||
if (parsedKey.type) {
|
if (parsedKey.type) {
|
||||||
keyType = parsedKey.type;
|
keyType = parsedKey.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate public key in SSH format
|
|
||||||
try {
|
try {
|
||||||
const publicKeyBuffer = parsedKey.getPublicSSH();
|
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||||
|
|
||||||
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
|
||||||
// We need to convert this to proper SSH public key format
|
|
||||||
if (Buffer.isBuffer(publicKeyBuffer)) {
|
if (Buffer.isBuffer(publicKeyBuffer)) {
|
||||||
// Convert binary SSH data to base64 and create proper SSH key format
|
|
||||||
const base64Data = publicKeyBuffer.toString("base64");
|
const base64Data = publicKeyBuffer.toString("base64");
|
||||||
|
|
||||||
// Create proper SSH public key format: "keytype base64data"
|
|
||||||
if (keyType === "ssh-rsa") {
|
if (keyType === "ssh-rsa") {
|
||||||
publicKey = `ssh-rsa ${base64Data}`;
|
publicKey = `ssh-rsa ${base64Data}`;
|
||||||
} else if (keyType === "ssh-ed25519") {
|
} else if (keyType === "ssh-ed25519") {
|
||||||
@@ -288,16 +242,12 @@ export function parseSSHKey(
|
|||||||
|
|
||||||
useSSH2 = true;
|
useSSH2 = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
// SSH2 parsing failed, will fall back to content detection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to content-based detection
|
|
||||||
if (!useSSH2) {
|
if (!useSSH2) {
|
||||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
|
|
||||||
// For fallback, we can't generate public key but the detection is still useful
|
|
||||||
publicKey = "";
|
publicKey = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +258,6 @@ export function parseSSHKey(
|
|||||||
success: keyType !== "unknown",
|
success: keyType !== "unknown",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Final fallback - try content detection
|
|
||||||
try {
|
try {
|
||||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
if (fallbackKeyType !== "unknown") {
|
if (fallbackKeyType !== "unknown") {
|
||||||
@@ -319,9 +268,7 @@ export function parseSSHKey(
|
|||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {}
|
||||||
// Even fallback detection failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
privateKey: privateKeyData,
|
privateKey: privateKeyData,
|
||||||
@@ -334,9 +281,6 @@ export function parseSSHKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SSH public key and extract type information
|
|
||||||
*/
|
|
||||||
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||||
try {
|
try {
|
||||||
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||||
@@ -359,9 +303,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect SSH key type from private key content
|
|
||||||
*/
|
|
||||||
export function detectKeyType(privateKeyData: string): string {
|
export function detectKeyType(privateKeyData: string): string {
|
||||||
try {
|
try {
|
||||||
const parsedKey = ssh2Utils.parseKey(privateKeyData);
|
const parsedKey = ssh2Utils.parseKey(privateKeyData);
|
||||||
@@ -374,9 +315,6 @@ export function detectKeyType(privateKeyData: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get friendly key type name
|
|
||||||
*/
|
|
||||||
export function getFriendlyKeyTypeName(keyType: string): string {
|
export function getFriendlyKeyTypeName(keyType: string): string {
|
||||||
const keyTypeMap: Record<string, string> = {
|
const keyTypeMap: Record<string, string> = {
|
||||||
"ssh-rsa": "RSA",
|
"ssh-rsa": "RSA",
|
||||||
@@ -393,16 +331,12 @@ export function getFriendlyKeyTypeName(keyType: string): string {
|
|||||||
return keyTypeMap[keyType] || keyType;
|
return keyTypeMap[keyType] || keyType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if a private key and public key form a valid key pair
|
|
||||||
*/
|
|
||||||
export function validateKeyPair(
|
export function validateKeyPair(
|
||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
publicKeyData: string,
|
publicKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
): KeyPairValidationResult {
|
): KeyPairValidationResult {
|
||||||
try {
|
try {
|
||||||
// First parse the private key and try to generate public key
|
|
||||||
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||||
const publicKeyInfo = parsePublicKey(publicKeyData);
|
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||||
|
|
||||||
@@ -424,7 +358,6 @@ export function validateKeyPair(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if key types match
|
|
||||||
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
|
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -434,17 +367,14 @@ export function validateKeyPair(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a generated public key from the private key, compare them
|
|
||||||
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
||||||
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||||
const providedPublicKey = publicKeyData.trim();
|
const providedPublicKey = publicKeyData.trim();
|
||||||
|
|
||||||
// Compare the key data part (excluding comments)
|
|
||||||
const generatedKeyParts = generatedPublicKey.split(" ");
|
const generatedKeyParts = generatedPublicKey.split(" ");
|
||||||
const providedKeyParts = providedPublicKey.split(" ");
|
const providedKeyParts = providedPublicKey.split(" ");
|
||||||
|
|
||||||
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
||||||
// Compare key type and key data (first two parts)
|
|
||||||
const generatedKeyData =
|
const generatedKeyData =
|
||||||
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
||||||
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
||||||
@@ -468,9 +398,8 @@ export function validateKeyPair(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we can't generate public key or compare, just check if types match
|
|
||||||
return {
|
return {
|
||||||
isValid: true, // Assume valid if types match and no errors
|
isValid: true,
|
||||||
privateKeyType: privateKeyInfo.keyType,
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
publicKeyType: publicKeyInfo.keyType,
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
error: "Unable to verify key pair match, but key types are compatible",
|
error: "Unable to verify key pair match, but key types are compatible",
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ import { promises as fs } from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { databaseLogger } from "./logger.js";
|
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 {
|
class SystemCrypto {
|
||||||
private static instance: SystemCrypto;
|
private static instance: SystemCrypto;
|
||||||
private jwtSecret: string | null = null;
|
private jwtSecret: string | null = null;
|
||||||
private databaseKey: Buffer | null = null;
|
private databaseKey: Buffer | null = null;
|
||||||
private internalAuthToken: string | null = null;
|
private internalAuthToken: string | null = null;
|
||||||
|
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
static getInstance(): SystemCrypto {
|
static getInstance(): SystemCrypto {
|
||||||
@@ -28,25 +18,15 @@ class SystemCrypto {
|
|||||||
return this.instance;
|
return this.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize JWT secret - environment variable only
|
|
||||||
*/
|
|
||||||
async initializeJWTSecret(): Promise<void> {
|
async initializeJWTSecret(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Initializing JWT secret", {
|
|
||||||
operation: "jwt_init",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check environment variable
|
|
||||||
const envSecret = process.env.JWT_SECRET;
|
const envSecret = process.env.JWT_SECRET;
|
||||||
if (envSecret && envSecret.length >= 64) {
|
if (envSecret && envSecret.length >= 64) {
|
||||||
this.jwtSecret = envSecret;
|
this.jwtSecret = envSecret;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No environment variable - generate and guide user
|
|
||||||
await this.generateAndGuideUser();
|
await this.generateAndGuideUser();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||||
operation: "jwt_init_failed",
|
operation: "jwt_init_failed",
|
||||||
@@ -55,9 +35,6 @@ class SystemCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get JWT secret
|
|
||||||
*/
|
|
||||||
async getJWTSecret(): Promise<string> {
|
async getJWTSecret(): Promise<string> {
|
||||||
if (!this.jwtSecret) {
|
if (!this.jwtSecret) {
|
||||||
await this.initializeJWTSecret();
|
await this.initializeJWTSecret();
|
||||||
@@ -65,36 +42,15 @@ class SystemCrypto {
|
|||||||
return this.jwtSecret!;
|
return this.jwtSecret!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize database encryption key - environment variable only
|
|
||||||
*/
|
|
||||||
async initializeDatabaseKey(): Promise<void> {
|
async initializeDatabaseKey(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Initializing database encryption key", {
|
|
||||||
operation: "db_key_init",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check environment variable
|
|
||||||
const envKey = process.env.DATABASE_KEY;
|
const envKey = process.env.DATABASE_KEY;
|
||||||
databaseLogger.info("Checking DATABASE_KEY from environment", {
|
|
||||||
operation: "db_key_check",
|
|
||||||
hasKey: !!envKey,
|
|
||||||
keyLength: envKey?.length || 0,
|
|
||||||
meetsLengthRequirement: envKey && envKey.length >= 64
|
|
||||||
});
|
|
||||||
|
|
||||||
if (envKey && envKey.length >= 64) {
|
if (envKey && envKey.length >= 64) {
|
||||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
this.databaseKey = Buffer.from(envKey, "hex");
|
||||||
databaseLogger.info("Using existing DATABASE_KEY from environment", {
|
|
||||||
operation: "db_key_use_existing",
|
|
||||||
keyLength: envKey.length
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No environment variable - generate and guide user
|
|
||||||
await this.generateAndGuideDatabaseKey();
|
await this.generateAndGuideDatabaseKey();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize database key", error, {
|
databaseLogger.error("Failed to initialize database key", error, {
|
||||||
operation: "db_key_init_failed",
|
operation: "db_key_init_failed",
|
||||||
@@ -103,9 +59,6 @@ class SystemCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database encryption key
|
|
||||||
*/
|
|
||||||
async getDatabaseKey(): Promise<Buffer> {
|
async getDatabaseKey(): Promise<Buffer> {
|
||||||
if (!this.databaseKey) {
|
if (!this.databaseKey) {
|
||||||
await this.initializeDatabaseKey();
|
await this.initializeDatabaseKey();
|
||||||
@@ -113,25 +66,15 @@ class SystemCrypto {
|
|||||||
return this.databaseKey!;
|
return this.databaseKey!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize internal auth token - environment variable only
|
|
||||||
*/
|
|
||||||
async initializeInternalAuthToken(): Promise<void> {
|
async initializeInternalAuthToken(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Initializing internal auth token", {
|
|
||||||
operation: "internal_auth_init",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check environment variable
|
|
||||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||||
if (envToken && envToken.length >= 32) {
|
if (envToken && envToken.length >= 32) {
|
||||||
this.internalAuthToken = envToken;
|
this.internalAuthToken = envToken;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No environment variable - generate and guide user
|
|
||||||
await this.generateAndGuideInternalAuthToken();
|
await this.generateAndGuideInternalAuthToken();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||||
operation: "internal_auth_init_failed",
|
operation: "internal_auth_init_failed",
|
||||||
@@ -140,9 +83,6 @@ class SystemCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get internal auth token
|
|
||||||
*/
|
|
||||||
async getInternalAuthToken(): Promise<string> {
|
async getInternalAuthToken(): Promise<string> {
|
||||||
if (!this.internalAuthToken) {
|
if (!this.internalAuthToken) {
|
||||||
await this.initializeInternalAuthToken();
|
await this.initializeInternalAuthToken();
|
||||||
@@ -150,79 +90,58 @@ class SystemCrypto {
|
|||||||
return this.internalAuthToken!;
|
return this.internalAuthToken!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate and auto-save to .env file
|
|
||||||
*/
|
|
||||||
private async generateAndGuideUser(): Promise<void> {
|
private async generateAndGuideUser(): Promise<void> {
|
||||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||||
|
|
||||||
// Set in memory for current session
|
|
||||||
this.jwtSecret = newSecret;
|
this.jwtSecret = newSecret;
|
||||||
|
|
||||||
// Auto-save to .env file
|
|
||||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||||
|
|
||||||
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
||||||
operation: "jwt_auto_generated",
|
operation: "jwt_auto_generated",
|
||||||
instanceId,
|
instanceId,
|
||||||
envVarName: "JWT_SECRET",
|
envVarName: "JWT_SECRET",
|
||||||
note: "Ready for use - no restart required"
|
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> {
|
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||||
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
const newKey = crypto.randomBytes(32);
|
||||||
const newKeyHex = newKey.toString('hex');
|
const newKeyHex = newKey.toString("hex");
|
||||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||||
|
|
||||||
// Set in memory for current session
|
|
||||||
this.databaseKey = newKey;
|
this.databaseKey = newKey;
|
||||||
|
|
||||||
// Auto-save to .env file
|
|
||||||
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
|
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
|
||||||
|
|
||||||
databaseLogger.success("🔒 Database key auto-generated and saved to .env", {
|
databaseLogger.success("Database key auto-generated and saved to .env", {
|
||||||
operation: "db_key_auto_generated",
|
operation: "db_key_auto_generated",
|
||||||
instanceId,
|
instanceId,
|
||||||
envVarName: "DATABASE_KEY",
|
envVarName: "DATABASE_KEY",
|
||||||
note: "Ready for use - no restart required"
|
note: "Ready for use - no restart required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate and auto-save internal auth token to .env file
|
|
||||||
*/
|
|
||||||
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
||||||
const newToken = crypto.randomBytes(32).toString('hex'); // 256-bit token for security
|
const newToken = crypto.randomBytes(32).toString("hex");
|
||||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||||
|
|
||||||
// Set in memory for current session
|
|
||||||
this.internalAuthToken = newToken;
|
this.internalAuthToken = newToken;
|
||||||
|
|
||||||
// Auto-save to .env file
|
|
||||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||||
|
|
||||||
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
|
databaseLogger.success(
|
||||||
|
"Internal auth token auto-generated and saved to .env",
|
||||||
|
{
|
||||||
operation: "internal_auth_auto_generated",
|
operation: "internal_auth_auto_generated",
|
||||||
instanceId,
|
instanceId,
|
||||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||||
note: "Ready for use - no restart required"
|
note: "Ready for use - no restart required",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate JWT secret system
|
|
||||||
*/
|
|
||||||
async validateJWTSecret(): Promise<boolean> {
|
async validateJWTSecret(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const secret = await this.getJWTSecret();
|
const secret = await this.getJWTSecret();
|
||||||
@@ -230,7 +149,6 @@ class SystemCrypto {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test JWT operations
|
|
||||||
const jwt = await import("jsonwebtoken");
|
const jwt = await import("jsonwebtoken");
|
||||||
const testPayload = { test: true, timestamp: Date.now() };
|
const testPayload = { test: true, timestamp: Date.now() };
|
||||||
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
|
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
|
||||||
@@ -245,81 +163,58 @@ class SystemCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get JWT key status (simplified version)
|
|
||||||
*/
|
|
||||||
async getSystemKeyStatus() {
|
async getSystemKeyStatus() {
|
||||||
const isValid = await this.validateJWTSecret();
|
const isValid = await this.validateJWTSecret();
|
||||||
const hasSecret = this.jwtSecret !== null;
|
const hasSecret = this.jwtSecret !== null;
|
||||||
|
|
||||||
|
const hasEnvVar = !!(
|
||||||
// Check environment variable
|
process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64
|
||||||
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasSecret,
|
hasSecret,
|
||||||
isValid,
|
isValid,
|
||||||
storage: {
|
storage: {
|
||||||
environment: hasEnvVar
|
environment: hasEnvVar,
|
||||||
},
|
},
|
||||||
algorithm: "HS256",
|
algorithm: "HS256",
|
||||||
note: "Using simplified key management without encryption layers"
|
note: "Using simplified key management without encryption layers",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update .env file with new environment variable
|
|
||||||
*/
|
|
||||||
private async updateEnvFile(key: string, value: string): Promise<void> {
|
private async updateEnvFile(key: string, value: string): Promise<void> {
|
||||||
// Use data directory for .env file (where database is stored)
|
|
||||||
// This keeps keys and data together in one volume
|
|
||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const envPath = path.join(dataDir, ".env");
|
const envPath = path.join(dataDir, ".env");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure data directory exists
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
let envContent = "";
|
let envContent = "";
|
||||||
|
|
||||||
// Read existing .env file if it exists
|
|
||||||
try {
|
try {
|
||||||
envContent = await fs.readFile(envPath, "utf8");
|
envContent = await fs.readFile(envPath, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist, will create new one
|
|
||||||
envContent = "# Termix Auto-generated Configuration\n\n";
|
envContent = "# Termix Auto-generated Configuration\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if key already exists
|
|
||||||
const keyRegex = new RegExp(`^${key}=.*$`, "m");
|
const keyRegex = new RegExp(`^${key}=.*$`, "m");
|
||||||
|
|
||||||
if (keyRegex.test(envContent)) {
|
if (keyRegex.test(envContent)) {
|
||||||
// Update existing key
|
|
||||||
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
||||||
} else {
|
} else {
|
||||||
// Add new key
|
|
||||||
if (!envContent.includes("# Security Keys")) {
|
if (!envContent.includes("# Security Keys")) {
|
||||||
envContent += "\n# Security Keys (Auto-generated)\n";
|
envContent += "\n# Security Keys (Auto-generated)\n";
|
||||||
}
|
}
|
||||||
envContent += `${key}=${value}\n`;
|
envContent += `${key}=${value}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write updated content
|
|
||||||
await fs.writeFile(envPath, envContent);
|
await fs.writeFile(envPath, envContent);
|
||||||
|
|
||||||
// Update process.env for current session
|
|
||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
|
|
||||||
databaseLogger.info(`Environment variable ${key} updated in .env file`, {
|
|
||||||
operation: "env_file_update",
|
|
||||||
key,
|
|
||||||
path: envPath
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
|
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
|
||||||
operation: "env_file_update_failed",
|
operation: "env_file_update_failed",
|
||||||
key
|
key,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,37 +20,29 @@ interface EncryptedDEK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserSession {
|
interface UserSession {
|
||||||
dataKey: Buffer; // Store DEK directly, delete just-in-time fantasy
|
dataKey: Buffer;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UserCrypto - Simple direct user encryption
|
|
||||||
*
|
|
||||||
* Linus principles:
|
|
||||||
* - Delete just-in-time fantasy, cache DEK directly
|
|
||||||
* - Reasonable 24-hour timeout with 6-hour inactivity, not 5-minute user experience disaster
|
|
||||||
* - Simple working implementation, not theoretically perfect garbage
|
|
||||||
* - Server restart invalidates sessions (this is reasonable)
|
|
||||||
*/
|
|
||||||
class UserCrypto {
|
class UserCrypto {
|
||||||
private static instance: UserCrypto;
|
private static instance: UserCrypto;
|
||||||
private userSessions: Map<string, UserSession> = new Map();
|
private userSessions: Map<string, UserSession> = new Map();
|
||||||
private sessionExpiredCallback?: (userId: string) => void; // Callback for session expiration
|
private sessionExpiredCallback?: (userId: string) => void;
|
||||||
|
|
||||||
// Configuration constants - reasonable timeout settings
|
|
||||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||||
private static readonly KEK_LENGTH = 32;
|
private static readonly KEK_LENGTH = 32;
|
||||||
private static readonly DEK_LENGTH = 32;
|
private static readonly DEK_LENGTH = 32;
|
||||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 hours, full day session
|
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; // 6 hours, reasonable inactivity timeout
|
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Reasonable cleanup interval
|
setInterval(
|
||||||
setInterval(() => {
|
() => {
|
||||||
this.cleanupExpiredSessions();
|
this.cleanupExpiredSessions();
|
||||||
}, 5 * 60 * 1000); // Clean every 5 minutes, not 30 seconds
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): UserCrypto {
|
static getInstance(): UserCrypto {
|
||||||
@@ -60,16 +52,10 @@ class UserCrypto {
|
|||||||
return this.instance;
|
return this.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set callback for session expiration (used by AuthManager)
|
|
||||||
*/
|
|
||||||
setSessionExpiredCallback(callback: (userId: string) => void): void {
|
setSessionExpiredCallback(callback: (userId: string) => void): void {
|
||||||
this.sessionExpiredCallback = callback;
|
this.sessionExpiredCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* User registration: generate KEK salt and DEK
|
|
||||||
*/
|
|
||||||
async setupUserEncryption(userId: string, password: string): Promise<void> {
|
async setupUserEncryption(userId: string, password: string): Promise<void> {
|
||||||
const kekSalt = await this.generateKEKSalt();
|
const kekSalt = await this.generateKEKSalt();
|
||||||
await this.storeKEKSalt(userId, kekSalt);
|
await this.storeKEKSalt(userId, kekSalt);
|
||||||
@@ -79,23 +65,12 @@ class UserCrypto {
|
|||||||
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
||||||
await this.storeEncryptedDEK(userId, encryptedDEK);
|
await this.storeEncryptedDEK(userId, encryptedDEK);
|
||||||
|
|
||||||
// Immediately clean temporary keys
|
|
||||||
KEK.fill(0);
|
KEK.fill(0);
|
||||||
DEK.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> {
|
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Validate password and decrypt DEK
|
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const kekSalt = await this.getKEKSalt(userId);
|
||||||
if (!kekSalt) return false;
|
if (!kekSalt) return false;
|
||||||
|
|
||||||
@@ -107,40 +82,31 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||||
KEK.fill(0); // Immediately clean KEK
|
KEK.fill(0);
|
||||||
|
|
||||||
// Debug: Check DEK validity
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
databaseLogger.error("DEK is empty or invalid after decryption", {
|
databaseLogger.error("DEK is empty or invalid after decryption", {
|
||||||
operation: "user_crypto_auth_debug",
|
operation: "user_crypto_auth_debug",
|
||||||
userId,
|
userId,
|
||||||
dekLength: DEK ? DEK.length : 0
|
dekLength: DEK ? DEK.length : 0,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user session, cache DEK directly
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Clean old session
|
|
||||||
const oldSession = this.userSessions.get(userId);
|
const oldSession = this.userSessions.get(userId);
|
||||||
if (oldSession) {
|
if (oldSession) {
|
||||||
oldSession.dataKey.fill(0);
|
oldSession.dataKey.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userSessions.set(userId, {
|
this.userSessions.set(userId, {
|
||||||
dataKey: Buffer.from(DEK), // Create proper Buffer copy
|
dataKey: Buffer.from(DEK),
|
||||||
lastActivity: now,
|
lastActivity: now,
|
||||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||||
});
|
});
|
||||||
|
|
||||||
DEK.fill(0); // Clean temporary DEK
|
DEK.fill(0);
|
||||||
|
|
||||||
databaseLogger.success("User authenticated and DEK cached", {
|
|
||||||
operation: "user_crypto_auth",
|
|
||||||
userId,
|
|
||||||
duration: UserCrypto.SESSION_DURATION,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -153,10 +119,6 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data key - simple direct return from cache
|
|
||||||
* Deleted just-in-time derivation garbage
|
|
||||||
*/
|
|
||||||
getUserDataKey(userId: string): Buffer | null {
|
getUserDataKey(userId: string): Buffer | null {
|
||||||
const session = this.userSessions.get(userId);
|
const session = this.userSessions.get(userId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -165,74 +127,49 @@ class UserCrypto {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Check if session has expired
|
|
||||||
if (now > session.expiresAt) {
|
if (now > session.expiresAt) {
|
||||||
this.userSessions.delete(userId);
|
this.userSessions.delete(userId);
|
||||||
session.dataKey.fill(0);
|
session.dataKey.fill(0);
|
||||||
databaseLogger.info("User session expired", {
|
|
||||||
operation: "user_session_expired",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
// Trigger callback to invalidate JWT tokens
|
|
||||||
if (this.sessionExpiredCallback) {
|
if (this.sessionExpiredCallback) {
|
||||||
this.sessionExpiredCallback(userId);
|
this.sessionExpiredCallback(userId);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if max inactivity time exceeded
|
|
||||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||||
this.userSessions.delete(userId);
|
this.userSessions.delete(userId);
|
||||||
session.dataKey.fill(0);
|
session.dataKey.fill(0);
|
||||||
databaseLogger.info("User session inactive timeout", {
|
|
||||||
operation: "user_session_inactive",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
// Trigger callback to invalidate JWT tokens
|
|
||||||
if (this.sessionExpiredCallback) {
|
if (this.sessionExpiredCallback) {
|
||||||
this.sessionExpiredCallback(userId);
|
this.sessionExpiredCallback(userId);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last activity time
|
|
||||||
session.lastActivity = now;
|
session.lastActivity = now;
|
||||||
return session.dataKey;
|
return session.dataKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User logout: clear session
|
|
||||||
*/
|
|
||||||
logoutUser(userId: string): void {
|
logoutUser(userId: string): void {
|
||||||
const session = this.userSessions.get(userId);
|
const session = this.userSessions.get(userId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.dataKey.fill(0); // Securely clear key
|
session.dataKey.fill(0);
|
||||||
this.userSessions.delete(userId);
|
this.userSessions.delete(userId);
|
||||||
}
|
}
|
||||||
databaseLogger.info("User logged out", {
|
|
||||||
operation: "user_crypto_logout",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is unlocked
|
|
||||||
*/
|
|
||||||
isUserUnlocked(userId: string): boolean {
|
isUserUnlocked(userId: string): boolean {
|
||||||
return this.getUserDataKey(userId) !== null;
|
return this.getUserDataKey(userId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async changeUserPassword(
|
||||||
* Change user password
|
userId: string,
|
||||||
*/
|
oldPassword: string,
|
||||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
newPassword: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Validate old password
|
|
||||||
const isValid = await this.validatePassword(userId, oldPassword);
|
const isValid = await this.validatePassword(userId, oldPassword);
|
||||||
if (!isValid) return false;
|
if (!isValid) return false;
|
||||||
|
|
||||||
// Get current DEK
|
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const kekSalt = await this.getKEKSalt(userId);
|
||||||
if (!kekSalt) return false;
|
if (!kekSalt) return false;
|
||||||
|
|
||||||
@@ -242,21 +179,17 @@ class UserCrypto {
|
|||||||
|
|
||||||
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
|
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
|
||||||
|
|
||||||
// Generate new KEK salt and encrypt DEK
|
|
||||||
const newKekSalt = await this.generateKEKSalt();
|
const newKekSalt = await this.generateKEKSalt();
|
||||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||||
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
||||||
|
|
||||||
// Store new salt and encrypted DEK
|
|
||||||
await this.storeKEKSalt(userId, newKekSalt);
|
await this.storeKEKSalt(userId, newKekSalt);
|
||||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||||
|
|
||||||
// Clean all temporary keys
|
|
||||||
oldKEK.fill(0);
|
oldKEK.fill(0);
|
||||||
newKEK.fill(0);
|
newKEK.fill(0);
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
|
|
||||||
// Clean user session, require re-login
|
|
||||||
this.logoutUser(userId);
|
this.logoutUser(userId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -265,9 +198,10 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Private methods =====
|
private async validatePassword(
|
||||||
|
userId: string,
|
||||||
private async validatePassword(userId: string, password: string): Promise<boolean> {
|
password: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const kekSalt = await this.getKEKSalt(userId);
|
||||||
if (!kekSalt) return false;
|
if (!kekSalt) return false;
|
||||||
@@ -278,7 +212,6 @@ class UserCrypto {
|
|||||||
|
|
||||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||||
|
|
||||||
// Clean temporary keys
|
|
||||||
KEK.fill(0);
|
KEK.fill(0);
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
|
|
||||||
@@ -293,25 +226,19 @@ class UserCrypto {
|
|||||||
const expiredUsers: string[] = [];
|
const expiredUsers: string[] = [];
|
||||||
|
|
||||||
for (const [userId, session] of this.userSessions.entries()) {
|
for (const [userId, session] of this.userSessions.entries()) {
|
||||||
if (now > session.expiresAt || now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
if (
|
||||||
session.dataKey.fill(0); // Securely clear key
|
now > session.expiresAt ||
|
||||||
|
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
|
||||||
|
) {
|
||||||
|
session.dataKey.fill(0);
|
||||||
expiredUsers.push(userId);
|
expiredUsers.push(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expiredUsers.forEach(userId => {
|
expiredUsers.forEach((userId) => {
|
||||||
this.userSessions.delete(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> {
|
private async generateKEKSalt(): Promise<KEKSalt> {
|
||||||
return {
|
return {
|
||||||
@@ -328,7 +255,7 @@ class UserCrypto {
|
|||||||
Buffer.from(kekSalt.salt, "hex"),
|
Buffer.from(kekSalt.salt, "hex"),
|
||||||
kekSalt.iterations,
|
kekSalt.iterations,
|
||||||
UserCrypto.KEK_LENGTH,
|
UserCrypto.KEK_LENGTH,
|
||||||
"sha256"
|
"sha256",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +280,7 @@ class UserCrypto {
|
|||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
"aes-256-gcm",
|
"aes-256-gcm",
|
||||||
kek,
|
kek,
|
||||||
Buffer.from(encryptedDEK.iv, "hex")
|
Buffer.from(encryptedDEK.iv, "hex"),
|
||||||
);
|
);
|
||||||
|
|
||||||
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
|
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
|
||||||
@@ -363,15 +290,20 @@ class UserCrypto {
|
|||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database operation methods
|
|
||||||
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
||||||
const key = `user_kek_salt_${userId}`;
|
const key = `user_kek_salt_${userId}`;
|
||||||
const value = JSON.stringify(kekSalt);
|
const value = JSON.stringify(kekSalt);
|
||||||
|
|
||||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
const existing = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
await getDb()
|
||||||
|
.update(settings)
|
||||||
|
.set({ value })
|
||||||
|
.where(eq(settings.key, key));
|
||||||
} else {
|
} else {
|
||||||
await getDb().insert(settings).values({ key, value });
|
await getDb().insert(settings).values({ key, value });
|
||||||
}
|
}
|
||||||
@@ -380,7 +312,10 @@ class UserCrypto {
|
|||||||
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
||||||
try {
|
try {
|
||||||
const key = `user_kek_salt_${userId}`;
|
const key = `user_kek_salt_${userId}`;
|
||||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
const result = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -392,14 +327,23 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async storeEncryptedDEK(userId: string, encryptedDEK: EncryptedDEK): Promise<void> {
|
private async storeEncryptedDEK(
|
||||||
|
userId: string,
|
||||||
|
encryptedDEK: EncryptedDEK,
|
||||||
|
): Promise<void> {
|
||||||
const key = `user_encrypted_dek_${userId}`;
|
const key = `user_encrypted_dek_${userId}`;
|
||||||
const value = JSON.stringify(encryptedDEK);
|
const value = JSON.stringify(encryptedDEK);
|
||||||
|
|
||||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
const existing = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
await getDb()
|
||||||
|
.update(settings)
|
||||||
|
.set({ value })
|
||||||
|
.where(eq(settings.key, key));
|
||||||
} else {
|
} else {
|
||||||
await getDb().insert(settings).values({ key, value });
|
await getDb().insert(settings).values({ key, value });
|
||||||
}
|
}
|
||||||
@@ -408,7 +352,10 @@ class UserCrypto {
|
|||||||
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
||||||
try {
|
try {
|
||||||
const key = `user_encrypted_dek_${userId}`;
|
const key = `user_encrypted_dek_${userId}`;
|
||||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
const result = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -419,7 +366,6 @@ class UserCrypto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
import {
|
||||||
|
users,
|
||||||
|
sshData,
|
||||||
|
sshCredentials,
|
||||||
|
fileManagerRecent,
|
||||||
|
fileManagerPinned,
|
||||||
|
fileManagerShortcuts,
|
||||||
|
dismissedAlerts,
|
||||||
|
} from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
interface UserExportData {
|
interface UserExportData {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -23,87 +30,98 @@ interface UserExportData {
|
|||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
encrypted: boolean;
|
encrypted: boolean;
|
||||||
exportType: 'user_data' | 'system_config' | 'all';
|
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 {
|
class UserDataExport {
|
||||||
private static readonly EXPORT_VERSION = "v2.0";
|
private static readonly EXPORT_VERSION = "v2.0";
|
||||||
|
|
||||||
/**
|
|
||||||
* Export user data
|
|
||||||
*/
|
|
||||||
static async exportUserData(
|
static async exportUserData(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: {
|
options: {
|
||||||
format?: 'encrypted' | 'plaintext';
|
format?: "encrypted" | "plaintext";
|
||||||
scope?: 'user_data' | 'all';
|
scope?: "user_data" | "all";
|
||||||
includeCredentials?: boolean;
|
includeCredentials?: boolean;
|
||||||
} = {}
|
} = {},
|
||||||
): Promise<UserExportData> {
|
): Promise<UserExportData> {
|
||||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
|
const {
|
||||||
|
format = "encrypted",
|
||||||
|
scope = "user_data",
|
||||||
|
includeCredentials = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Starting user data export", {
|
const user = await getDb()
|
||||||
operation: "user_data_export",
|
.select()
|
||||||
userId,
|
.from(users)
|
||||||
format,
|
.where(eq(users.id, userId));
|
||||||
scope,
|
|
||||||
includeCredentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify user exists
|
|
||||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
throw new Error(`User not found: ${userId}`);
|
throw new Error(`User not found: ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
// Get user data key (if decryption needed)
|
|
||||||
let userDataKey: Buffer | null = null;
|
let userDataKey: Buffer | null = null;
|
||||||
if (format === 'plaintext') {
|
if (format === "plaintext") {
|
||||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("User data not unlocked - password required for plaintext export");
|
throw new Error(
|
||||||
|
"User data not unlocked - password required for plaintext export",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export SSH host configurations
|
const sshHosts = await getDb()
|
||||||
const sshHosts = await getDb().select().from(sshData).where(eq(sshData.userId, userId));
|
.select()
|
||||||
const processedSshHosts = format === 'plaintext' && userDataKey
|
.from(sshData)
|
||||||
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
|
.where(eq(sshData.userId, userId));
|
||||||
|
const processedSshHosts =
|
||||||
|
format === "plaintext" && userDataKey
|
||||||
|
? sshHosts.map((host) =>
|
||||||
|
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
|
||||||
|
)
|
||||||
: sshHosts;
|
: sshHosts;
|
||||||
|
|
||||||
// Export SSH credentials (if included)
|
|
||||||
let sshCredentialsData: any[] = [];
|
let sshCredentialsData: any[] = [];
|
||||||
if (includeCredentials) {
|
if (includeCredentials) {
|
||||||
const credentials = await getDb().select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
|
const credentials = await getDb()
|
||||||
sshCredentialsData = format === 'plaintext' && userDataKey
|
.select()
|
||||||
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId));
|
||||||
|
sshCredentialsData =
|
||||||
|
format === "plaintext" && userDataKey
|
||||||
|
? credentials.map((cred) =>
|
||||||
|
DataCrypto.decryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
cred,
|
||||||
|
userId,
|
||||||
|
userDataKey!,
|
||||||
|
),
|
||||||
|
)
|
||||||
: credentials;
|
: credentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export file manager data
|
|
||||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||||
getDb().select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
|
getDb()
|
||||||
getDb().select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
|
.select()
|
||||||
getDb().select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
|
.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()
|
||||||
const alerts = await getDb().select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
.select()
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
// Build export data
|
|
||||||
const exportData: UserExportData = {
|
const exportData: UserExportData = {
|
||||||
version: this.EXPORT_VERSION,
|
version: this.EXPORT_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
@@ -120,8 +138,14 @@ class UserDataExport {
|
|||||||
dismissedAlerts: alerts,
|
dismissedAlerts: alerts,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
|
totalRecords:
|
||||||
encrypted: format === 'encrypted',
|
processedSshHosts.length +
|
||||||
|
sshCredentialsData.length +
|
||||||
|
recentFiles.length +
|
||||||
|
pinnedFiles.length +
|
||||||
|
shortcuts.length +
|
||||||
|
alerts.length,
|
||||||
|
encrypted: format === "encrypted",
|
||||||
exportType: scope,
|
exportType: scope,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -147,30 +171,24 @@ class UserDataExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Export as JSON string
|
|
||||||
*/
|
|
||||||
static async exportUserDataToJSON(
|
static async exportUserDataToJSON(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: {
|
options: {
|
||||||
format?: 'encrypted' | 'plaintext';
|
format?: "encrypted" | "plaintext";
|
||||||
scope?: 'user_data' | 'all';
|
scope?: "user_data" | "all";
|
||||||
includeCredentials?: boolean;
|
includeCredentials?: boolean;
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
} = {}
|
} = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { pretty = true } = options;
|
const { pretty = true } = options;
|
||||||
const exportData = await this.exportUserData(userId, options);
|
const exportData = await this.exportUserData(userId, options);
|
||||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate export data format
|
|
||||||
*/
|
|
||||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== "object") {
|
||||||
errors.push("Export data must be an object");
|
errors.push("Export data must be an object");
|
||||||
return { valid: false, errors };
|
return { valid: false, errors };
|
||||||
}
|
}
|
||||||
@@ -183,28 +201,43 @@ class UserDataExport {
|
|||||||
errors.push("Missing userId field");
|
errors.push("Missing userId field");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.userData || typeof data.userData !== 'object') {
|
if (!data.userData || typeof data.userData !== "object") {
|
||||||
errors.push("Missing or invalid userData field");
|
errors.push("Missing or invalid userData field");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.metadata || typeof data.metadata !== 'object') {
|
if (!data.metadata || typeof data.metadata !== "object") {
|
||||||
errors.push("Missing or invalid metadata field");
|
errors.push("Missing or invalid metadata field");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required data fields
|
|
||||||
if (data.userData) {
|
if (data.userData) {
|
||||||
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
|
const requiredFields = [
|
||||||
|
"sshHosts",
|
||||||
|
"sshCredentials",
|
||||||
|
"fileManagerData",
|
||||||
|
"dismissedAlerts",
|
||||||
|
];
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
if (!Array.isArray(data.userData[field]) && !(field === 'fileManagerData' && typeof data.userData[field] === 'object')) {
|
if (
|
||||||
|
!Array.isArray(data.userData[field]) &&
|
||||||
|
!(
|
||||||
|
field === "fileManagerData" &&
|
||||||
|
typeof data.userData[field] === "object"
|
||||||
|
)
|
||||||
|
) {
|
||||||
errors.push(`Missing or invalid userData.${field} field`);
|
errors.push(`Missing or invalid userData.${field} field`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
|
if (
|
||||||
const fmFields = ['recent', 'pinned', 'shortcuts'];
|
data.userData.fileManagerData &&
|
||||||
|
typeof data.userData.fileManagerData === "object"
|
||||||
|
) {
|
||||||
|
const fmFields = ["recent", "pinned", "shortcuts"];
|
||||||
for (const field of fmFields) {
|
for (const field of fmFields) {
|
||||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||||
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
|
errors.push(
|
||||||
|
`Missing or invalid userData.fileManagerData.${field} field`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,9 +246,6 @@ class UserDataExport {
|
|||||||
return { valid: errors.length === 0, errors };
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get export data statistics
|
|
||||||
*/
|
|
||||||
static getExportStats(data: UserExportData): {
|
static getExportStats(data: UserExportData): {
|
||||||
version: string;
|
version: string;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
@@ -237,7 +267,8 @@ class UserDataExport {
|
|||||||
breakdown: {
|
breakdown: {
|
||||||
sshHosts: data.userData.sshHosts.length,
|
sshHosts: data.userData.sshHosts.length,
|
||||||
sshCredentials: data.userData.sshCredentials.length,
|
sshCredentials: data.userData.sshCredentials.length,
|
||||||
fileManagerItems: data.userData.fileManagerData.recent.length +
|
fileManagerItems:
|
||||||
|
data.userData.fileManagerData.recent.length +
|
||||||
data.userData.fileManagerData.pinned.length +
|
data.userData.fileManagerData.pinned.length +
|
||||||
data.userData.fileManagerData.shortcuts.length,
|
data.userData.fileManagerData.shortcuts.length,
|
||||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
import {
|
||||||
|
users,
|
||||||
|
sshData,
|
||||||
|
sshCredentials,
|
||||||
|
fileManagerRecent,
|
||||||
|
fileManagerPinned,
|
||||||
|
fileManagerShortcuts,
|
||||||
|
dismissedAlerts,
|
||||||
|
} from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||||
@@ -26,62 +34,40 @@ interface ImportResult {
|
|||||||
dryRun: boolean;
|
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 {
|
class UserDataImport {
|
||||||
|
|
||||||
/**
|
|
||||||
* Import user data
|
|
||||||
*/
|
|
||||||
static async importUserData(
|
static async importUserData(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
exportData: UserExportData,
|
exportData: UserExportData,
|
||||||
options: ImportOptions = {}
|
options: ImportOptions = {},
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
const {
|
const {
|
||||||
replaceExisting = false,
|
replaceExisting = false,
|
||||||
skipCredentials = false,
|
skipCredentials = false,
|
||||||
skipFileManagerData = false,
|
skipFileManagerData = false,
|
||||||
dryRun = false
|
dryRun = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Starting user data import", {
|
const targetUser = await getDb()
|
||||||
operation: "user_data_import",
|
.select()
|
||||||
targetUserId,
|
.from(users)
|
||||||
sourceUserId: exportData.userId,
|
.where(eq(users.id, targetUserId));
|
||||||
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) {
|
if (!targetUser || targetUser.length === 0) {
|
||||||
throw new Error(`Target user not found: ${targetUserId}`);
|
throw new Error(`Target user not found: ${targetUserId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate export data format
|
|
||||||
const validation = UserDataExport.validateExportData(exportData);
|
const validation = UserDataExport.validateExportData(exportData);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
|
throw new Error(`Invalid export data: ${validation.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user data is unlocked (if data is encrypted)
|
|
||||||
let userDataKey: Buffer | null = null;
|
let userDataKey: Buffer | null = null;
|
||||||
if (exportData.metadata.encrypted) {
|
if (exportData.metadata.encrypted) {
|
||||||
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("Target user data not unlocked - password required for encrypted import");
|
throw new Error(
|
||||||
|
"Target user data not unlocked - password required for encrypted import",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,48 +84,54 @@ class UserDataImport {
|
|||||||
dryRun,
|
dryRun,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import SSH host configurations
|
if (
|
||||||
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
|
exportData.userData.sshHosts &&
|
||||||
|
exportData.userData.sshHosts.length > 0
|
||||||
|
) {
|
||||||
const importStats = await this.importSshHosts(
|
const importStats = await this.importSshHosts(
|
||||||
targetUserId,
|
targetUserId,
|
||||||
exportData.userData.sshHosts,
|
exportData.userData.sshHosts,
|
||||||
{ replaceExisting, dryRun, userDataKey }
|
{ replaceExisting, dryRun, userDataKey },
|
||||||
);
|
);
|
||||||
result.summary.sshHostsImported = importStats.imported;
|
result.summary.sshHostsImported = importStats.imported;
|
||||||
result.summary.skippedItems += importStats.skipped;
|
result.summary.skippedItems += importStats.skipped;
|
||||||
result.summary.errors.push(...importStats.errors);
|
result.summary.errors.push(...importStats.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import SSH credentials
|
if (
|
||||||
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
|
!skipCredentials &&
|
||||||
|
exportData.userData.sshCredentials &&
|
||||||
|
exportData.userData.sshCredentials.length > 0
|
||||||
|
) {
|
||||||
const importStats = await this.importSshCredentials(
|
const importStats = await this.importSshCredentials(
|
||||||
targetUserId,
|
targetUserId,
|
||||||
exportData.userData.sshCredentials,
|
exportData.userData.sshCredentials,
|
||||||
{ replaceExisting, dryRun, userDataKey }
|
{ replaceExisting, dryRun, userDataKey },
|
||||||
);
|
);
|
||||||
result.summary.sshCredentialsImported = importStats.imported;
|
result.summary.sshCredentialsImported = importStats.imported;
|
||||||
result.summary.skippedItems += importStats.skipped;
|
result.summary.skippedItems += importStats.skipped;
|
||||||
result.summary.errors.push(...importStats.errors);
|
result.summary.errors.push(...importStats.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import file manager data
|
|
||||||
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
||||||
const importStats = await this.importFileManagerData(
|
const importStats = await this.importFileManagerData(
|
||||||
targetUserId,
|
targetUserId,
|
||||||
exportData.userData.fileManagerData,
|
exportData.userData.fileManagerData,
|
||||||
{ replaceExisting, dryRun }
|
{ replaceExisting, dryRun },
|
||||||
);
|
);
|
||||||
result.summary.fileManagerItemsImported = importStats.imported;
|
result.summary.fileManagerItemsImported = importStats.imported;
|
||||||
result.summary.skippedItems += importStats.skipped;
|
result.summary.skippedItems += importStats.skipped;
|
||||||
result.summary.errors.push(...importStats.errors);
|
result.summary.errors.push(...importStats.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import dismissed alerts
|
if (
|
||||||
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
|
exportData.userData.dismissedAlerts &&
|
||||||
|
exportData.userData.dismissedAlerts.length > 0
|
||||||
|
) {
|
||||||
const importStats = await this.importDismissedAlerts(
|
const importStats = await this.importDismissedAlerts(
|
||||||
targetUserId,
|
targetUserId,
|
||||||
exportData.userData.dismissedAlerts,
|
exportData.userData.dismissedAlerts,
|
||||||
{ replaceExisting, dryRun }
|
{ replaceExisting, dryRun },
|
||||||
);
|
);
|
||||||
result.summary.dismissedAlertsImported = importStats.imported;
|
result.summary.dismissedAlertsImported = importStats.imported;
|
||||||
result.summary.skippedItems += importStats.skipped;
|
result.summary.skippedItems += importStats.skipped;
|
||||||
@@ -166,13 +158,14 @@ class UserDataImport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import SSH host configurations
|
|
||||||
*/
|
|
||||||
private static async importSshHosts(
|
private static async importSshHosts(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
sshHosts: any[],
|
sshHosts: any[],
|
||||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
options: {
|
||||||
|
replaceExisting: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
userDataKey: Buffer | null;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -185,29 +178,33 @@ class UserDataImport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate temporary ID for encryption context, then remove for database insert
|
|
||||||
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
||||||
const newHostData = {
|
const newHostData = {
|
||||||
...host,
|
...host,
|
||||||
id: tempId, // Temporary ID for encryption context
|
id: tempId,
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// If data needs re-encryption
|
|
||||||
let processedHostData = newHostData;
|
let processedHostData = newHostData;
|
||||||
if (options.userDataKey) {
|
if (options.userDataKey) {
|
||||||
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
|
processedHostData = DataCrypto.encryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
newHostData,
|
||||||
|
targetUserId,
|
||||||
|
options.userDataKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove temp ID to let database auto-generate real ID
|
|
||||||
delete processedHostData.id;
|
delete processedHostData.id;
|
||||||
|
|
||||||
await getDb().insert(sshData).values(processedHostData);
|
await getDb().insert(sshData).values(processedHostData);
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,13 +212,14 @@ class UserDataImport {
|
|||||||
return { imported, skipped, errors };
|
return { imported, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import SSH credentials
|
|
||||||
*/
|
|
||||||
private static async importSshCredentials(
|
private static async importSshCredentials(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
credentials: any[],
|
credentials: any[],
|
||||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
options: {
|
||||||
|
replaceExisting: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
userDataKey: Buffer | null;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -234,31 +232,35 @@ class UserDataImport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate temporary ID for encryption context, then remove for database insert
|
|
||||||
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
||||||
const newCredentialData = {
|
const newCredentialData = {
|
||||||
...credential,
|
...credential,
|
||||||
id: tempCredId, // Temporary ID for encryption context
|
id: tempCredId,
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
usageCount: 0, // Reset usage count
|
usageCount: 0,
|
||||||
lastUsed: null,
|
lastUsed: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// If data needs re-encryption
|
|
||||||
let processedCredentialData = newCredentialData;
|
let processedCredentialData = newCredentialData;
|
||||||
if (options.userDataKey) {
|
if (options.userDataKey) {
|
||||||
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
|
processedCredentialData = DataCrypto.encryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
newCredentialData,
|
||||||
|
targetUserId,
|
||||||
|
options.userDataKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove temp ID to let database auto-generate real ID
|
|
||||||
delete processedCredentialData.id;
|
delete processedCredentialData.id;
|
||||||
|
|
||||||
await getDb().insert(sshCredentials).values(processedCredentialData);
|
await getDb().insert(sshCredentials).values(processedCredentialData);
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,20 +268,16 @@ class UserDataImport {
|
|||||||
return { imported, skipped, errors };
|
return { imported, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import file manager data
|
|
||||||
*/
|
|
||||||
private static async importFileManagerData(
|
private static async importFileManagerData(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
fileManagerData: any,
|
fileManagerData: any,
|
||||||
options: { replaceExisting: boolean; dryRun: boolean }
|
options: { replaceExisting: boolean; dryRun: boolean },
|
||||||
) {
|
) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import recent files
|
|
||||||
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
||||||
for (const item of fileManagerData.recent) {
|
for (const item of fileManagerData.recent) {
|
||||||
try {
|
try {
|
||||||
@@ -294,13 +292,14 @@ class UserDataImport {
|
|||||||
}
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`Recent file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import pinned files
|
|
||||||
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
||||||
for (const item of fileManagerData.pinned) {
|
for (const item of fileManagerData.pinned) {
|
||||||
try {
|
try {
|
||||||
@@ -315,14 +314,18 @@ class UserDataImport {
|
|||||||
}
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`Pinned file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import shortcuts
|
if (
|
||||||
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
|
fileManagerData.shortcuts &&
|
||||||
|
Array.isArray(fileManagerData.shortcuts)
|
||||||
|
) {
|
||||||
for (const item of fileManagerData.shortcuts) {
|
for (const item of fileManagerData.shortcuts) {
|
||||||
try {
|
try {
|
||||||
if (!options.dryRun) {
|
if (!options.dryRun) {
|
||||||
@@ -336,25 +339,26 @@ class UserDataImport {
|
|||||||
}
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`Shortcut import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`File manager data import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { imported, skipped, errors };
|
return { imported, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import dismissed alerts
|
|
||||||
*/
|
|
||||||
private static async importDismissedAlerts(
|
private static async importDismissedAlerts(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
alerts: any[],
|
alerts: any[],
|
||||||
options: { replaceExisting: boolean; dryRun: boolean }
|
options: { replaceExisting: boolean; dryRun: boolean },
|
||||||
) {
|
) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -367,15 +371,14 @@ class UserDataImport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if alert already exists
|
|
||||||
const existing = await getDb()
|
const existing = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(dismissedAlerts)
|
.from(dismissedAlerts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(dismissedAlerts.userId, targetUserId),
|
eq(dismissedAlerts.userId, targetUserId),
|
||||||
eq(dismissedAlerts.alertId, alert.alertId)
|
eq(dismissedAlerts.alertId, alert.alertId),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.length > 0 && !options.replaceExisting) {
|
if (existing.length > 0 && !options.replaceExisting) {
|
||||||
@@ -401,7 +404,9 @@ class UserDataImport {
|
|||||||
|
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`Dismissed alert import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
errors.push(
|
||||||
|
`Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,13 +414,10 @@ class UserDataImport {
|
|||||||
return { imported, skipped, errors };
|
return { imported, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import from JSON string
|
|
||||||
*/
|
|
||||||
static async importUserDataFromJSON(
|
static async importUserDataFromJSON(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
jsonData: string,
|
jsonData: string,
|
||||||
options: ImportOptions = {}
|
options: ImportOptions = {},
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
try {
|
try {
|
||||||
const exportData: UserExportData = JSON.parse(jsonData);
|
const exportData: UserExportData = JSON.parse(jsonData);
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function VersionAlert({
|
|||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
|
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("versionCheck.currentVersion", { version: updateInfo.localVersion })}
|
{t("versionCheck.currentVersion", {
|
||||||
|
version: updateInfo.localVersion,
|
||||||
|
})}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
@@ -73,10 +75,14 @@ export function VersionAlert({
|
|||||||
|
|
||||||
{updateInfo.latest_release && (
|
{updateInfo.latest_release && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="font-medium">{updateInfo.latest_release.name}</div>
|
<div className="font-medium">
|
||||||
|
{updateInfo.latest_release.name}
|
||||||
|
</div>
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{t("versionCheck.releasedOn", {
|
{t("versionCheck.releasedOn", {
|
||||||
date: new Date(updateInfo.latest_release.published_at).toLocaleDateString(),
|
date: new Date(
|
||||||
|
updateInfo.latest_release.published_at,
|
||||||
|
).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
-1
@@ -18,7 +18,6 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||||
|
|
||||||
// Drag and drop API
|
|
||||||
createTempFile: (fileData: {
|
createTempFile: (fileData: {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// CENTRAL TYPE DEFINITIONS
|
// CENTRAL TYPE DEFINITIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// This file contains all shared interfaces and types used across the application
|
// This file contains all shared interfaces and types used across the application
|
||||||
// to avoid duplication and ensure consistency.
|
|
||||||
|
|
||||||
import type { Client } from "ssh2";
|
import type { Client } from "ssh2";
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export interface SSHHost {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
|
|
||||||
// Autostart plaintext credentials
|
|
||||||
autostartPassword?: string;
|
autostartPassword?: string;
|
||||||
autostartKey?: string;
|
autostartKey?: string;
|
||||||
autostartKeyPassword?: string;
|
autostartKeyPassword?: string;
|
||||||
|
|||||||
@@ -91,10 +91,8 @@ export function AdminSettings({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Simplified security state
|
|
||||||
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
||||||
|
|
||||||
// Database migration state
|
|
||||||
const [exportLoading, setExportLoading] = React.useState(false);
|
const [exportLoading, setExportLoading] = React.useState(false);
|
||||||
const [importLoading, setImportLoading] = React.useState(false);
|
const [importLoading, setImportLoading] = React.useState(false);
|
||||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||||
@@ -103,9 +101,6 @@ export function AdminSettings({
|
|||||||
const [importPassword, setImportPassword] = React.useState("");
|
const [importPassword, setImportPassword] = React.useState("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
// No need to check for JWT cookie manually
|
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
const serverUrl = (window as any).configuredServerUrl;
|
const serverUrl = (window as any).configuredServerUrl;
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
@@ -147,9 +142,6 @@ export function AdminSettings({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
// No need to check for JWT cookie manually
|
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
const serverUrl = (window as any).configuredServerUrl;
|
const serverUrl = (window as any).configuredServerUrl;
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
@@ -172,7 +164,6 @@ export function AdminSettings({
|
|||||||
|
|
||||||
const handleToggleRegistration = async (checked: boolean) => {
|
const handleToggleRegistration = async (checked: boolean) => {
|
||||||
setRegLoading(true);
|
setRegLoading(true);
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
try {
|
try {
|
||||||
await updateRegistrationAllowed(checked);
|
await updateRegistrationAllowed(checked);
|
||||||
setAllowRegistration(checked);
|
setAllowRegistration(checked);
|
||||||
@@ -204,7 +195,6 @@ export function AdminSettings({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
try {
|
try {
|
||||||
await updateOIDCConfig(oidcConfig);
|
await updateOIDCConfig(oidcConfig);
|
||||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||||
@@ -226,7 +216,6 @@ export function AdminSettings({
|
|||||||
if (!newAdminUsername.trim()) return;
|
if (!newAdminUsername.trim()) return;
|
||||||
setMakeAdminLoading(true);
|
setMakeAdminLoading(true);
|
||||||
setMakeAdminError(null);
|
setMakeAdminError(null);
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
try {
|
try {
|
||||||
await makeUserAdmin(newAdminUsername.trim());
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
|
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
|
||||||
@@ -243,7 +232,6 @@ export function AdminSettings({
|
|||||||
|
|
||||||
const handleRemoveAdminStatus = async (username: string) => {
|
const handleRemoveAdminStatus = async (username: string) => {
|
||||||
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
|
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
try {
|
try {
|
||||||
await removeAdminStatus(username);
|
await removeAdminStatus(username);
|
||||||
toast.success(t("admin.adminStatusRemoved", { username }));
|
toast.success(t("admin.adminStatusRemoved", { username }));
|
||||||
@@ -258,7 +246,6 @@ export function AdminSettings({
|
|||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("admin.deleteUser", { username }),
|
t("admin.deleteUser", { username }),
|
||||||
async () => {
|
async () => {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
try {
|
try {
|
||||||
await deleteUser(username);
|
await deleteUser(username);
|
||||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||||
@@ -271,14 +258,6 @@ export function AdminSettings({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkSecurityStatus = async () => {
|
|
||||||
// New v2-kek-dek system is always initialized
|
|
||||||
setSecurityInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Database export/import handlers
|
|
||||||
const handleExportDatabase = async () => {
|
const handleExportDatabase = async () => {
|
||||||
if (!showPasswordInput) {
|
if (!showPasswordInput) {
|
||||||
setShowPasswordInput(true);
|
setShowPasswordInput(true);
|
||||||
@@ -292,7 +271,6 @@ export function AdminSettings({
|
|||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
const apiUrl = isElectron()
|
const apiUrl = isElectron()
|
||||||
? `${(window as any).configuredServerUrl}/database/export`
|
? `${(window as any).configuredServerUrl}/database/export`
|
||||||
: "http://localhost:30001/database/export";
|
: "http://localhost:30001/database/export";
|
||||||
@@ -302,18 +280,19 @@ export function AdminSettings({
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
credentials: "include", // Include HttpOnly cookies
|
credentials: "include",
|
||||||
body: JSON.stringify({ password: exportPassword }),
|
body: JSON.stringify({ password: exportPassword }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Handle file download
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const contentDisposition = response.headers.get('content-disposition');
|
const contentDisposition = response.headers.get("content-disposition");
|
||||||
const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || 'termix-export.sqlite';
|
const filename =
|
||||||
|
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||||
|
"termix-export.sqlite";
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
@@ -352,19 +331,17 @@ export function AdminSettings({
|
|||||||
|
|
||||||
setImportLoading(true);
|
setImportLoading(true);
|
||||||
try {
|
try {
|
||||||
// JWT is now automatically sent via HttpOnly cookies
|
|
||||||
const apiUrl = isElectron()
|
const apiUrl = isElectron()
|
||||||
? `${(window as any).configuredServerUrl}/database/import`
|
? `${(window as any).configuredServerUrl}/database/import`
|
||||||
: "http://localhost:30001/database/import";
|
: "http://localhost:30001/database/import";
|
||||||
|
|
||||||
// Create FormData for file upload
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", importFile);
|
formData.append("file", importFile);
|
||||||
formData.append("password", importPassword);
|
formData.append("password", importPassword);
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include", // Include HttpOnly cookies
|
credentials: "include",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,23 +349,34 @@ export function AdminSettings({
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const summary = result.summary;
|
const summary = result.summary;
|
||||||
const imported = summary.sshHostsImported + summary.sshCredentialsImported + summary.fileManagerItemsImported + summary.dismissedAlertsImported + (summary.settingsImported || 0);
|
const imported =
|
||||||
|
summary.sshHostsImported +
|
||||||
|
summary.sshCredentialsImported +
|
||||||
|
summary.fileManagerItemsImported +
|
||||||
|
summary.dismissedAlertsImported +
|
||||||
|
(summary.settingsImported || 0);
|
||||||
const skipped = summary.skippedItems;
|
const skipped = summary.skippedItems;
|
||||||
|
|
||||||
const details = [];
|
const details = [];
|
||||||
if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`);
|
if (summary.sshHostsImported > 0)
|
||||||
if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`);
|
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||||
if (summary.fileManagerItemsImported > 0) details.push(`${summary.fileManagerItemsImported} file manager items`);
|
if (summary.sshCredentialsImported > 0)
|
||||||
if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`);
|
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||||
if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`);
|
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(
|
toast.success(
|
||||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(', ')})` : ''}, ${skipped} items skipped`
|
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||||
);
|
);
|
||||||
setImportFile(null);
|
setImportFile(null);
|
||||||
setImportPassword("");
|
setImportPassword("");
|
||||||
|
|
||||||
// Refresh the page to show imported data
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -412,7 +400,6 @@ export function AdminSettings({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -856,18 +843,20 @@ export function AdminSettings({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simple status display - read only */}
|
|
||||||
<div className="p-4 border rounded bg-card">
|
<div className="p-4 border rounded bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lock className="h-4 w-4 text-green-500" />
|
<Lock className="h-4 w-4 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
|
<div className="text-sm font-medium">
|
||||||
<div className="text-xs text-green-500">{t("admin.encryptionEnabled")}</div>
|
{t("admin.encryptionStatus")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-green-500">
|
||||||
|
{t("admin.encryptionEnabled")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data management functions - export/import */}
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="p-4 border rounded bg-card">
|
<div className="p-4 border rounded bg-card">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -887,7 +876,7 @@ export function AdminSettings({
|
|||||||
onChange={(e) => setExportPassword(e.target.value)}
|
onChange={(e) => setExportPassword(e.target.value)}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
handleExportDatabase();
|
handleExportDatabase();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -903,8 +892,7 @@ export function AdminSettings({
|
|||||||
? t("admin.exporting")
|
? t("admin.exporting")
|
||||||
: showPasswordInput
|
: showPasswordInput
|
||||||
? t("admin.confirmExport")
|
? t("admin.confirmExport")
|
||||||
: t("admin.export")
|
: t("admin.export")}
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
{showPasswordInput && (
|
{showPasswordInput && (
|
||||||
<Button
|
<Button
|
||||||
@@ -935,7 +923,9 @@ export function AdminSettings({
|
|||||||
id="import-file-upload"
|
id="import-file-upload"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".sqlite,.db"
|
accept=".sqlite,.db"
|
||||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
onChange={(e) =>
|
||||||
|
setImportFile(e.target.files?.[0] || null)
|
||||||
|
}
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -945,7 +935,10 @@ export function AdminSettings({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="truncate"
|
className="truncate"
|
||||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
title={
|
||||||
|
importFile?.name ||
|
||||||
|
t("admin.pleaseSelectImportFile")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{importFile
|
{importFile
|
||||||
? importFile.name
|
? importFile.name
|
||||||
@@ -962,7 +955,7 @@ export function AdminSettings({
|
|||||||
onChange={(e) => setImportPassword(e.target.value)}
|
onChange={(e) => setImportPassword(e.target.value)}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
handleImportDatabase();
|
handleImportDatabase();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -971,10 +964,14 @@ export function AdminSettings({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImportDatabase}
|
onClick={handleImportDatabase}
|
||||||
disabled={importLoading || !importFile || !importPassword.trim()}
|
disabled={
|
||||||
|
importLoading || !importFile || !importPassword.trim()
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
{importLoading
|
||||||
|
? t("admin.importing")
|
||||||
|
: t("admin.import")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (keyDetectionTimeoutRef.current) {
|
if (keyDetectionTimeoutRef.current) {
|
||||||
@@ -234,7 +233,6 @@ export function CredentialEditor({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Detect key type function
|
|
||||||
const handleKeyTypeDetection = async (
|
const handleKeyTypeDetection = async (
|
||||||
keyValue: string,
|
keyValue: string,
|
||||||
keyPassword?: string,
|
keyPassword?: string,
|
||||||
@@ -251,7 +249,6 @@ export function CredentialEditor({
|
|||||||
setDetectedKeyType(result.keyType);
|
setDetectedKeyType(result.keyType);
|
||||||
} else {
|
} else {
|
||||||
setDetectedKeyType("invalid");
|
setDetectedKeyType("invalid");
|
||||||
console.warn("Key detection failed:", result.error);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDetectedKeyType("error");
|
setDetectedKeyType("error");
|
||||||
@@ -261,7 +258,6 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced key type detection
|
|
||||||
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||||||
if (keyDetectionTimeoutRef.current) {
|
if (keyDetectionTimeoutRef.current) {
|
||||||
clearTimeout(keyDetectionTimeoutRef.current);
|
clearTimeout(keyDetectionTimeoutRef.current);
|
||||||
@@ -271,7 +267,6 @@ export function CredentialEditor({
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Detect public key type function
|
|
||||||
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
|
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
|
||||||
if (!publicKeyValue || publicKeyValue.trim() === "") {
|
if (!publicKeyValue || publicKeyValue.trim() === "") {
|
||||||
setDetectedPublicKeyType(null);
|
setDetectedPublicKeyType(null);
|
||||||
@@ -295,7 +290,6 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced public key type detection
|
|
||||||
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
|
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
|
||||||
if (publicKeyDetectionTimeoutRef.current) {
|
if (publicKeyDetectionTimeoutRef.current) {
|
||||||
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||||
@@ -718,7 +712,6 @@ export function CredentialEditor({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
form.setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
form.setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
// Auto-fill the key password field if passphrase was used
|
|
||||||
if (keyGenerationPassphrase) {
|
if (keyGenerationPassphrase) {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"keyPassword",
|
"keyPassword",
|
||||||
@@ -770,7 +763,6 @@ export function CredentialEditor({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
form.setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
form.setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
// Auto-fill the key password field if passphrase was used
|
|
||||||
if (keyGenerationPassphrase) {
|
if (keyGenerationPassphrase) {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"keyPassword",
|
"keyPassword",
|
||||||
@@ -822,7 +814,6 @@ export function CredentialEditor({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
form.setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
form.setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
// Auto-fill the key password field if passphrase was used
|
|
||||||
if (keyGenerationPassphrase) {
|
if (keyGenerationPassphrase) {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"keyPassword",
|
"keyPassword",
|
||||||
@@ -924,7 +915,9 @@ export function CredentialEditor({
|
|||||||
form.watch("keyPassword"),
|
form.watch("keyPassword"),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder={t("placeholders.pastePrivateKey")}
|
placeholder={t(
|
||||||
|
"placeholders.pastePrivateKey",
|
||||||
|
)}
|
||||||
theme={oneDark}
|
theme={oneDark}
|
||||||
className="border border-input rounded-md"
|
className="border border-input rounded-md"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
@@ -1044,9 +1037,7 @@ export function CredentialEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.publicKey) {
|
if (result.success && result.publicKey) {
|
||||||
// Set the generated public key
|
|
||||||
field.onChange(result.publicKey);
|
field.onChange(result.publicKey);
|
||||||
// Trigger public key detection
|
|
||||||
debouncedPublicKeyDetection(
|
debouncedPublicKeyDetection(
|
||||||
result.publicKey,
|
result.publicKey,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === "overview" ? "default" : "ghost"}
|
variant={activeTab === "overview" ? "default" : "ghost"}
|
||||||
@@ -249,7 +248,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === "overview" && (
|
{activeTab === "overview" && (
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||||
|
|||||||
@@ -804,7 +804,6 @@ export function CredentialsManager({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Credential Information Card */}
|
|
||||||
{deployingCredential && (
|
{deployingCredential && (
|
||||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
||||||
@@ -856,7 +855,6 @@ export function CredentialsManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Target Host Selection */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
||||||
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
||||||
@@ -888,7 +886,6 @@ export function CredentialsManager({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Information Note */}
|
|
||||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,6 @@ export function FileManagerContextMenu({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
|
|
||||||
// Adjust menu position to avoid going off screen
|
|
||||||
const adjustPosition = () => {
|
const adjustPosition = () => {
|
||||||
const menuWidth = 200;
|
const menuWidth = 200;
|
||||||
const menuHeight = 300;
|
const menuHeight = 300;
|
||||||
@@ -130,13 +129,10 @@ export function FileManagerContextMenu({
|
|||||||
|
|
||||||
adjustPosition();
|
adjustPosition();
|
||||||
|
|
||||||
// Delay adding event listeners to avoid capturing the click that triggered the menu
|
|
||||||
let cleanupFn: (() => void) | null = null;
|
let cleanupFn: (() => void) | null = null;
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
// Click outside to close menu
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
// Check if click is inside menu
|
|
||||||
const target = event.target as Element;
|
const target = event.target as Element;
|
||||||
const menuElement = document.querySelector("[data-context-menu]");
|
const menuElement = document.querySelector("[data-context-menu]");
|
||||||
|
|
||||||
@@ -145,13 +141,11 @@ export function FileManagerContextMenu({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Right-click to close menu (Windows behavior)
|
|
||||||
const handleRightClick = (event: MouseEvent) => {
|
const handleRightClick = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard support
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -159,12 +153,10 @@ export function FileManagerContextMenu({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close menu on window blur
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close menu on scroll (Windows behavior)
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -175,7 +167,6 @@ export function FileManagerContextMenu({
|
|||||||
window.addEventListener("blur", handleBlur);
|
window.addEventListener("blur", handleBlur);
|
||||||
window.addEventListener("scroll", handleScroll, true);
|
window.addEventListener("scroll", handleScroll, true);
|
||||||
|
|
||||||
// Set cleanup function
|
|
||||||
cleanupFn = () => {
|
cleanupFn = () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||||
document.removeEventListener("contextmenu", handleRightClick);
|
document.removeEventListener("contextmenu", handleRightClick);
|
||||||
@@ -183,7 +174,7 @@ export function FileManagerContextMenu({
|
|||||||
window.removeEventListener("blur", handleBlur);
|
window.removeEventListener("blur", handleBlur);
|
||||||
window.removeEventListener("scroll", handleScroll, true);
|
window.removeEventListener("scroll", handleScroll, true);
|
||||||
};
|
};
|
||||||
}, 50); // 50ms delay to ensure we don't capture the click that triggered the menu
|
}, 50);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -204,13 +195,9 @@ export function FileManagerContextMenu({
|
|||||||
(f) => f.type === "file" && f.executable,
|
(f) => f.type === "file" && f.executable,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build menu items
|
|
||||||
const menuItems: MenuItem[] = [];
|
const menuItems: MenuItem[] = [];
|
||||||
|
|
||||||
if (isFileContext) {
|
if (isFileContext) {
|
||||||
// Menu when files/folders are selected
|
|
||||||
|
|
||||||
// Open terminal function - supports files and folders
|
|
||||||
if (onOpenTerminal) {
|
if (onOpenTerminal) {
|
||||||
const targetPath = isSingleFile
|
const targetPath = isSingleFile
|
||||||
? files[0].type === "directory"
|
? files[0].type === "directory"
|
||||||
@@ -229,7 +216,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executable file function - only show for single executable files
|
|
||||||
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Play className="w-4 h-4" />,
|
icon: <Play className="w-4 h-4" />,
|
||||||
@@ -239,7 +225,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if above functions exist)
|
|
||||||
if (
|
if (
|
||||||
onOpenTerminal ||
|
onOpenTerminal ||
|
||||||
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
||||||
@@ -247,7 +232,6 @@ export function FileManagerContextMenu({
|
|||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview function
|
|
||||||
if (hasFiles && onPreview) {
|
if (hasFiles && onPreview) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Eye className="w-4 h-4" />,
|
icon: <Eye className="w-4 h-4" />,
|
||||||
@@ -257,7 +241,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download function - use proper download handler
|
|
||||||
if (hasFiles && onDownload) {
|
if (hasFiles && onDownload) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Download className="w-4 h-4" />,
|
icon: <Download className="w-4 h-4" />,
|
||||||
@@ -269,7 +252,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PIN/UNPIN function - only show for single files
|
|
||||||
if (isSingleFile && files[0].type === "file") {
|
if (isSingleFile && files[0].type === "file") {
|
||||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||||
|
|
||||||
@@ -288,7 +270,6 @@ export function FileManagerContextMenu({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add folder shortcut - only show for single folders
|
|
||||||
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Bookmark className="w-4 h-4" />,
|
icon: <Bookmark className="w-4 h-4" />,
|
||||||
@@ -297,7 +278,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if above functions exist)
|
|
||||||
if (
|
if (
|
||||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||||
(isSingleFile &&
|
(isSingleFile &&
|
||||||
@@ -308,7 +288,6 @@ export function FileManagerContextMenu({
|
|||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename function
|
|
||||||
if (isSingleFile && onRename) {
|
if (isSingleFile && onRename) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Edit3 className="w-4 h-4" />,
|
icon: <Edit3 className="w-4 h-4" />,
|
||||||
@@ -318,7 +297,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy function
|
|
||||||
if (onCopy) {
|
if (onCopy) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Copy className="w-4 h-4" />,
|
icon: <Copy className="w-4 h-4" />,
|
||||||
@@ -330,7 +308,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cut function
|
|
||||||
if (onCut) {
|
if (onCut) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Scissors className="w-4 h-4" />,
|
icon: <Scissors className="w-4 h-4" />,
|
||||||
@@ -342,12 +319,10 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if edit functions exist)
|
|
||||||
if ((isSingleFile && onRename) || onCopy || onCut) {
|
if ((isSingleFile && onRename) || onCopy || onCut) {
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete function
|
|
||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Trash2 className="w-4 h-4" />,
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
@@ -360,12 +335,10 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if delete function exists)
|
|
||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Properties function
|
|
||||||
if (isSingleFile && onProperties) {
|
if (isSingleFile && onProperties) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Info className="w-4 h-4" />,
|
icon: <Info className="w-4 h-4" />,
|
||||||
@@ -374,9 +347,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Empty area right-click menu
|
|
||||||
|
|
||||||
// Open terminal in current directory
|
|
||||||
if (onOpenTerminal && currentPath) {
|
if (onOpenTerminal && currentPath) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Terminal className="w-4 h-4" />,
|
icon: <Terminal className="w-4 h-4" />,
|
||||||
@@ -386,7 +356,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload function
|
|
||||||
if (onUpload) {
|
if (onUpload) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Upload className="w-4 h-4" />,
|
icon: <Upload className="w-4 h-4" />,
|
||||||
@@ -396,12 +365,10 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if terminal or upload functions exist)
|
|
||||||
if ((onOpenTerminal && currentPath) || onUpload) {
|
if ((onOpenTerminal && currentPath) || onUpload) {
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// New folder
|
|
||||||
if (onNewFolder) {
|
if (onNewFolder) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <FolderPlus className="w-4 h-4" />,
|
icon: <FolderPlus className="w-4 h-4" />,
|
||||||
@@ -411,7 +378,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New file
|
|
||||||
if (onNewFile) {
|
if (onNewFile) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <FilePlus className="w-4 h-4" />,
|
icon: <FilePlus className="w-4 h-4" />,
|
||||||
@@ -421,12 +387,10 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add separator (if new functions exist)
|
|
||||||
if (onNewFolder || onNewFile) {
|
if (onNewFolder || onNewFile) {
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh function
|
|
||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <RefreshCw className="w-4 h-4" />,
|
icon: <RefreshCw className="w-4 h-4" />,
|
||||||
@@ -436,7 +400,6 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paste function
|
|
||||||
if (hasClipboard && onPaste) {
|
if (hasClipboard && onPaste) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Clipboard className="w-4 h-4" />,
|
icon: <Clipboard className="w-4 h-4" />,
|
||||||
@@ -447,15 +410,12 @@ export function FileManagerContextMenu({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out consecutive separators
|
|
||||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||||
if (!item.separator) return true;
|
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 prevItem = index > 0 ? menuItems[index - 1] : null;
|
||||||
const nextItem = index < menuItems.length - 1 ? 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) {
|
if (prevItem?.separator || nextItem?.separator) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -463,7 +423,6 @@ export function FileManagerContextMenu({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove separators at beginning and end
|
|
||||||
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
||||||
if (!item.separator) return true;
|
if (!item.separator) return true;
|
||||||
return index > 0 && index < filteredMenuItems.length - 1;
|
return index > 0 && index < filteredMenuItems.length - 1;
|
||||||
@@ -471,10 +430,8 @@ export function FileManagerContextMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Transparent overlay to capture click events */}
|
|
||||||
<div className="fixed inset-0 z-[99990]" />
|
<div className="fixed inset-0 z-[99990]" />
|
||||||
|
|
||||||
{/* Menu body */}
|
|
||||||
<div
|
<div
|
||||||
data-context-menu
|
data-context-menu
|
||||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] 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"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
MoreHorizontal,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
FileSymlink,
|
FileSymlink,
|
||||||
@@ -26,20 +25,16 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FileItem } from "../../../types/index.js";
|
import type { FileItem } from "../../../types/index.js";
|
||||||
|
|
||||||
// Linus-style data structure: separate creation intent from actual files
|
|
||||||
interface CreateIntent {
|
interface CreateIntent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'file' | 'directory';
|
type: "file" | "directory";
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
currentName: string;
|
currentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size
|
|
||||||
function formatFileSize(bytes?: number): string {
|
function formatFileSize(bytes?: number): string {
|
||||||
// Handle undefined or null cases
|
|
||||||
if (bytes === undefined || bytes === null) return "-";
|
if (bytes === undefined || bytes === null) return "-";
|
||||||
|
|
||||||
// Display 0-byte files as "0 B"
|
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
@@ -51,7 +46,6 @@ function formatFileSize(bytes?: number): string {
|
|||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display one decimal place for values less than 10, integers for values greater than 10
|
|
||||||
const formattedSize =
|
const formattedSize =
|
||||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||||
|
|
||||||
@@ -95,7 +89,6 @@ interface FileManagerGridProps {
|
|||||||
onSystemDragStart?: (files: FileItem[]) => void;
|
onSystemDragStart?: (files: FileItem[]) => void;
|
||||||
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
|
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
|
||||||
hasClipboard?: boolean;
|
hasClipboard?: boolean;
|
||||||
// Linus-style creation intent props
|
|
||||||
createIntent?: CreateIntent | null;
|
createIntent?: CreateIntent | null;
|
||||||
onConfirmCreate?: (name: string) => void;
|
onConfirmCreate?: (name: string) => void;
|
||||||
onCancelCreate?: () => void;
|
onCancelCreate?: () => void;
|
||||||
@@ -206,16 +199,12 @@ export function FileManagerGrid({
|
|||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Unified drag state management
|
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
type: "none",
|
type: "none",
|
||||||
files: [],
|
files: [],
|
||||||
counter: 0,
|
counter: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global mouse move listener - for drag tooltip following
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||||
if (dragState.type === "internal" && dragState.files.length > 0) {
|
if (dragState.type === "internal" && dragState.files.length > 0) {
|
||||||
@@ -235,11 +224,9 @@ export function FileManagerGrid({
|
|||||||
|
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Set initial name when starting edit
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingFile) {
|
if (editingFile) {
|
||||||
setEditingName(editingFile.name);
|
setEditingName(editingFile.name);
|
||||||
// Delay focus to ensure DOM is updated
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
editInputRef.current?.focus();
|
editInputRef.current?.focus();
|
||||||
editInputRef.current?.select();
|
editInputRef.current?.select();
|
||||||
@@ -247,7 +234,6 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
}, [editingFile]);
|
}, [editingFile]);
|
||||||
|
|
||||||
// Handle edit confirmation
|
|
||||||
const handleEditConfirm = () => {
|
const handleEditConfirm = () => {
|
||||||
if (
|
if (
|
||||||
editingFile &&
|
editingFile &&
|
||||||
@@ -260,13 +246,11 @@ export function FileManagerGrid({
|
|||||||
onCancelEdit?.();
|
onCancelEdit?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle edit cancellation
|
|
||||||
const handleEditCancel = () => {
|
const handleEditCancel = () => {
|
||||||
setEditingName("");
|
setEditingName("");
|
||||||
onCancelEdit?.();
|
onCancelEdit?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle input key events
|
|
||||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -277,9 +261,7 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// File drag handling function
|
|
||||||
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
|
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
|
||||||
// If dragged file is selected, drag all selected files
|
|
||||||
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
|
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
|
||||||
|
|
||||||
setDragState({
|
setDragState({
|
||||||
@@ -290,7 +272,6 @@ export function FileManagerGrid({
|
|||||||
mousePosition: { x: e.clientX, y: e.clientY },
|
mousePosition: { x: e.clientX, y: e.clientY },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set drag data, add internal drag identifier
|
|
||||||
const dragData = {
|
const dragData = {
|
||||||
type: "internal_files",
|
type: "internal_files",
|
||||||
files: filesToDrag.map((f) => f.path),
|
files: filesToDrag.map((f) => f.path),
|
||||||
@@ -303,7 +284,6 @@ export function FileManagerGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Only set target when dragging to different file and not being dragged file
|
|
||||||
if (
|
if (
|
||||||
dragState.type === "internal" &&
|
dragState.type === "internal" &&
|
||||||
!dragState.files.some((f) => f.path === targetFile.path)
|
!dragState.files.some((f) => f.path === targetFile.path)
|
||||||
@@ -317,7 +297,6 @@ export function FileManagerGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Clear drag target highlight
|
|
||||||
if (dragState.target?.path === targetFile.path) {
|
if (dragState.target?.path === targetFile.path) {
|
||||||
setDragState((prev) => ({ ...prev, target: undefined }));
|
setDragState((prev) => ({ ...prev, target: undefined }));
|
||||||
}
|
}
|
||||||
@@ -332,46 +311,23 @@ export function FileManagerGrid({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dragging to self
|
|
||||||
const isDroppingOnSelf = dragState.files.some(
|
const isDroppingOnSelf = dragState.files.some(
|
||||||
(f) => f.path === targetFile.path,
|
(f) => f.path === targetFile.path,
|
||||||
);
|
);
|
||||||
if (isDroppingOnSelf) {
|
if (isDroppingOnSelf) {
|
||||||
console.log("Ignoring drop on self");
|
|
||||||
setDragState({ type: "none", files: [], counter: 0 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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") {
|
if (targetFile.type === "directory") {
|
||||||
// Move operation
|
|
||||||
console.log(
|
|
||||||
"Moving files to directory:",
|
|
||||||
dragState.files.map((f) => f.name),
|
|
||||||
"to",
|
|
||||||
targetFile.name,
|
|
||||||
);
|
|
||||||
onFileDrop?.(dragState.files, targetFile);
|
onFileDrop?.(dragState.files, targetFile);
|
||||||
} else if (
|
} else if (
|
||||||
targetFile.type === "file" &&
|
targetFile.type === "file" &&
|
||||||
dragState.files.length === 1 &&
|
dragState.files.length === 1 &&
|
||||||
dragState.files[0].type === "file"
|
dragState.files[0].type === "file"
|
||||||
) {
|
) {
|
||||||
// Diff comparison operation
|
|
||||||
console.log(
|
|
||||||
"Comparing files:",
|
|
||||||
dragState.files[0].name,
|
|
||||||
"vs",
|
|
||||||
targetFile.name,
|
|
||||||
);
|
|
||||||
onFileDiff?.(dragState.files[0], targetFile);
|
onFileDiff?.(dragState.files[0], targetFile);
|
||||||
} else {
|
} else {
|
||||||
// Invalid operation, notify user
|
|
||||||
console.log("Invalid drag operation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDragState({ type: "none", files: [], counter: 0 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
@@ -381,7 +337,6 @@ export function FileManagerGrid({
|
|||||||
const draggedFiles = dragState.draggedFiles || [];
|
const draggedFiles = dragState.draggedFiles || [];
|
||||||
setDragState({ type: "none", files: [], counter: 0 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
|
|
||||||
// Trigger system-level drag end detection with dragged files
|
|
||||||
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
|
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,17 +353,14 @@ export function FileManagerGrid({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||||
|
|
||||||
// Navigation history management
|
|
||||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([
|
const [navigationHistory, setNavigationHistory] = useState<string[]>([
|
||||||
currentPath,
|
currentPath,
|
||||||
]);
|
]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(0);
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
|
||||||
// Path editing state
|
|
||||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||||
const [editPathValue, setEditPathValue] = useState(currentPath);
|
const [editPathValue, setEditPathValue] = useState(currentPath);
|
||||||
|
|
||||||
// Update navigation history
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastPath = navigationHistory[historyIndex];
|
const lastPath = navigationHistory[historyIndex];
|
||||||
if (currentPath !== lastPath) {
|
if (currentPath !== lastPath) {
|
||||||
@@ -419,7 +371,6 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
}, [currentPath]);
|
}, [currentPath]);
|
||||||
|
|
||||||
// Navigation functions
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (historyIndex > 0) {
|
if (historyIndex > 0) {
|
||||||
const newIndex = historyIndex - 1;
|
const newIndex = historyIndex - 1;
|
||||||
@@ -447,7 +398,6 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Path navigation
|
|
||||||
const pathParts = currentPath.split("/").filter(Boolean);
|
const pathParts = currentPath.split("/").filter(Boolean);
|
||||||
const navigateToPath = (index: number) => {
|
const navigateToPath = (index: number) => {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -458,7 +408,6 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Path editing functionality
|
|
||||||
const startEditingPath = () => {
|
const startEditingPath = () => {
|
||||||
setEditPathValue(currentPath);
|
setEditPathValue(currentPath);
|
||||||
setIsEditingPath(true);
|
setIsEditingPath(true);
|
||||||
@@ -472,7 +421,6 @@ export function FileManagerGrid({
|
|||||||
const confirmEditingPath = () => {
|
const confirmEditingPath = () => {
|
||||||
const trimmedPath = editPathValue.trim();
|
const trimmedPath = editPathValue.trim();
|
||||||
if (trimmedPath) {
|
if (trimmedPath) {
|
||||||
// Ensure path starts with /
|
|
||||||
const normalizedPath = trimmedPath.startsWith("/")
|
const normalizedPath = trimmedPath.startsWith("/")
|
||||||
? trimmedPath
|
? trimmedPath
|
||||||
: "/" + trimmedPath;
|
: "/" + trimmedPath;
|
||||||
@@ -491,31 +439,26 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync editPathValue with currentPath
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditingPath) {
|
if (!isEditingPath) {
|
||||||
setEditPathValue(currentPath);
|
setEditPathValue(currentPath);
|
||||||
}
|
}
|
||||||
}, [currentPath, isEditingPath]);
|
}, [currentPath, isEditingPath]);
|
||||||
|
|
||||||
// Drag and drop handling - distinguish internal file drag and external file upload
|
|
||||||
const handleDragEnter = useCallback(
|
const handleDragEnter = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Check if it's internal file drag
|
|
||||||
const isInternalDrag = dragState.type === "internal";
|
const isInternalDrag = dragState.type === "internal";
|
||||||
|
|
||||||
if (!isInternalDrag) {
|
if (!isInternalDrag) {
|
||||||
// Only show upload prompt for external file drag
|
|
||||||
setDragState((prev) => ({
|
setDragState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
type: "external",
|
type: "external",
|
||||||
counter: prev.counter + 1,
|
counter: prev.counter + 1,
|
||||||
}));
|
}));
|
||||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||||
// External drag detected
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -527,7 +470,6 @@ export function FileManagerGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Check if it's internal file drag
|
|
||||||
const isInternalDrag = dragState.type === "internal";
|
const isInternalDrag = dragState.type === "internal";
|
||||||
|
|
||||||
if (!isInternalDrag && dragState.type === "external") {
|
if (!isInternalDrag && dragState.type === "external") {
|
||||||
@@ -549,11 +491,9 @@ export function FileManagerGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Check if it's internal file drag
|
|
||||||
const isInternalDrag = dragState.type === "internal";
|
const isInternalDrag = dragState.type === "internal";
|
||||||
|
|
||||||
if (isInternalDrag) {
|
if (isInternalDrag) {
|
||||||
// Update mouse position
|
|
||||||
setDragState((prev) => ({
|
setDragState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
mousePosition: { x: e.clientX, y: e.clientY },
|
mousePosition: { x: e.clientX, y: e.clientY },
|
||||||
@@ -566,15 +506,11 @@ export function FileManagerGrid({
|
|||||||
[dragState.type],
|
[dragState.type],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mouse wheel event handling, ensure scrolling works normally
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
// Don't prevent default scroll behavior, let browser handle scrolling
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Box selection functionality implementation
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
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) {
|
if (e.target === e.currentTarget && e.button === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
@@ -585,7 +521,6 @@ export function FileManagerGrid({
|
|||||||
setSelectionStart({ x: startX, y: startY });
|
setSelectionStart({ x: startX, y: startY });
|
||||||
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
||||||
|
|
||||||
// Reset flag for just completed selection, prepare for new selection
|
|
||||||
setJustFinishedSelecting(false);
|
setJustFinishedSelecting(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -604,7 +539,6 @@ export function FileManagerGrid({
|
|||||||
|
|
||||||
setSelectionRect({ x, y, width, height });
|
setSelectionRect({ x, y, width, height });
|
||||||
|
|
||||||
// Detect intersection with file items, perform real-time selection
|
|
||||||
if (gridRef.current) {
|
if (gridRef.current) {
|
||||||
const fileElements =
|
const fileElements =
|
||||||
gridRef.current.querySelectorAll("[data-file-path]");
|
gridRef.current.querySelectorAll("[data-file-path]");
|
||||||
@@ -614,7 +548,6 @@ export function FileManagerGrid({
|
|||||||
const elementRect = element.getBoundingClientRect();
|
const elementRect = element.getBoundingClientRect();
|
||||||
const containerRect = gridRef.current!.getBoundingClientRect();
|
const containerRect = gridRef.current!.getBoundingClientRect();
|
||||||
|
|
||||||
// Simplify coordinate calculation - directly use coordinates relative to container
|
|
||||||
const relativeElementRect = {
|
const relativeElementRect = {
|
||||||
left: elementRect.left - containerRect.left,
|
left: elementRect.left - containerRect.left,
|
||||||
top: elementRect.top - containerRect.top,
|
top: elementRect.top - containerRect.top,
|
||||||
@@ -622,7 +555,6 @@ export function FileManagerGrid({
|
|||||||
bottom: elementRect.bottom - containerRect.top,
|
bottom: elementRect.bottom - containerRect.top,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Selection box coordinates
|
|
||||||
const selectionBox = {
|
const selectionBox = {
|
||||||
left: x,
|
left: x,
|
||||||
top: y,
|
top: y,
|
||||||
@@ -630,7 +562,6 @@ export function FileManagerGrid({
|
|||||||
bottom: y + height,
|
bottom: y + height,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if intersecting
|
|
||||||
const intersects = !(
|
const intersects = !(
|
||||||
relativeElementRect.right < selectionBox.left ||
|
relativeElementRect.right < selectionBox.left ||
|
||||||
relativeElementRect.left > selectionBox.right ||
|
relativeElementRect.left > selectionBox.right ||
|
||||||
@@ -642,21 +573,13 @@ export function FileManagerGrid({
|
|||||||
const filePath = element.getAttribute("data-file-path");
|
const filePath = element.getAttribute("data-file-path");
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
selectedPaths.push(filePath);
|
selectedPaths.push(filePath);
|
||||||
console.log("Selected file:", filePath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Total selected paths:", selectedPaths.length);
|
|
||||||
|
|
||||||
// Update selected files
|
|
||||||
const newSelection = files.filter((file) =>
|
const newSelection = files.filter((file) =>
|
||||||
selectedPaths.includes(file.path),
|
selectedPaths.includes(file.path),
|
||||||
);
|
);
|
||||||
console.log(
|
|
||||||
"New selection:",
|
|
||||||
newSelection.map((f) => f.name),
|
|
||||||
);
|
|
||||||
onSelectionChange(newSelection);
|
onSelectionChange(newSelection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,7 +594,6 @@ export function FileManagerGrid({
|
|||||||
setSelectionStart(null);
|
setSelectionStart(null);
|
||||||
setSelectionRect(null);
|
setSelectionRect(null);
|
||||||
|
|
||||||
// Only consider as box selection when movement distance is large enough, otherwise it's a click
|
|
||||||
const startPos = selectionStart;
|
const startPos = selectionStart;
|
||||||
if (startPos) {
|
if (startPos) {
|
||||||
const rect = gridRef.current?.getBoundingClientRect();
|
const rect = gridRef.current?.getBoundingClientRect();
|
||||||
@@ -683,13 +605,11 @@ export function FileManagerGrid({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (distance > 5) {
|
if (distance > 5) {
|
||||||
// Real box selection, set flag to prevent immediate clearing
|
|
||||||
setJustFinishedSelecting(true);
|
setJustFinishedSelecting(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setJustFinishedSelecting(false);
|
setJustFinishedSelecting(false);
|
||||||
}, 50);
|
}, 50);
|
||||||
} else {
|
} else {
|
||||||
// Just a click, don't set flag, let handleGridClick handle normally
|
|
||||||
setJustFinishedSelecting(false);
|
setJustFinishedSelecting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,7 +619,6 @@ export function FileManagerGrid({
|
|||||||
[isSelecting, selectionStart],
|
[isSelecting, selectionStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global mouse event listener, ensure box selection can end outside container
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||||
if (isSelecting) {
|
if (isSelecting) {
|
||||||
@@ -707,7 +626,6 @@ export function FileManagerGrid({
|
|||||||
setSelectionStart(null);
|
setSelectionStart(null);
|
||||||
setSelectionRect(null);
|
setSelectionRect(null);
|
||||||
|
|
||||||
// Global mouseup indicates drag box selection, set flag
|
|
||||||
setJustFinishedSelecting(true);
|
setJustFinishedSelecting(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setJustFinishedSelecting(false);
|
setJustFinishedSelecting(false);
|
||||||
@@ -747,58 +665,32 @@ export function FileManagerGrid({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (dragState.type === "internal") {
|
if (dragState.type === "internal") {
|
||||||
// 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 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
} else if (dragState.type === "external") {
|
} else if (dragState.type === "external") {
|
||||||
// External drag: handle file upload
|
|
||||||
if (onUpload && e.dataTransfer.files.length > 0) {
|
if (onUpload && e.dataTransfer.files.length > 0) {
|
||||||
onUpload(e.dataTransfer.files);
|
onUpload(e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset drag state
|
|
||||||
setDragState({ type: "none", files: [], counter: 0 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
},
|
},
|
||||||
[onUpload, onDownload, dragState],
|
[onUpload, onDownload, dragState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// File selection handling
|
|
||||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// Ensure grid gets focus to support keyboard events
|
|
||||||
if (gridRef.current) {
|
if (gridRef.current) {
|
||||||
gridRef.current.focus();
|
gridRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
"File clicked:",
|
|
||||||
file.name,
|
|
||||||
"Current selected:",
|
|
||||||
selectedFiles.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (event.detail === 2) {
|
if (event.detail === 2) {
|
||||||
// Double click to open
|
|
||||||
console.log("Double click - opening file");
|
|
||||||
onFileOpen(file);
|
onFileOpen(file);
|
||||||
} else {
|
} else {
|
||||||
// Single click to select
|
|
||||||
const multiSelect = event.ctrlKey || event.metaKey;
|
const multiSelect = event.ctrlKey || event.metaKey;
|
||||||
const rangeSelect = event.shiftKey;
|
const rangeSelect = event.shiftKey;
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Single click - multiSelect:",
|
|
||||||
multiSelect,
|
|
||||||
"rangeSelect:",
|
|
||||||
rangeSelect,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rangeSelect && selectedFiles.length > 0) {
|
if (rangeSelect && selectedFiles.length > 0) {
|
||||||
// Range selection (Shift+click)
|
|
||||||
console.log("Range selection");
|
|
||||||
const lastSelected = selectedFiles[selectedFiles.length - 1];
|
const lastSelected = selectedFiles[selectedFiles.length - 1];
|
||||||
const currentIndex = files.findIndex((f) => f.path === file.path);
|
const currentIndex = files.findIndex((f) => f.path === file.path);
|
||||||
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
|
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
|
||||||
@@ -807,36 +699,26 @@ export function FileManagerGrid({
|
|||||||
const start = Math.min(currentIndex, lastIndex);
|
const start = Math.min(currentIndex, lastIndex);
|
||||||
const end = Math.max(currentIndex, lastIndex);
|
const end = Math.max(currentIndex, lastIndex);
|
||||||
const rangeFiles = files.slice(start, end + 1);
|
const rangeFiles = files.slice(start, end + 1);
|
||||||
console.log("Range selection result:", rangeFiles.length, "files");
|
|
||||||
onSelectionChange(rangeFiles);
|
onSelectionChange(rangeFiles);
|
||||||
}
|
}
|
||||||
} else if (multiSelect) {
|
} else if (multiSelect) {
|
||||||
// Multi-selection (Ctrl+click)
|
|
||||||
console.log("Multi selection");
|
|
||||||
const isSelected = selectedFiles.some((f) => f.path === file.path);
|
const isSelected = selectedFiles.some((f) => f.path === file.path);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
console.log("Removing from selection");
|
|
||||||
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
|
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
|
||||||
} else {
|
} else {
|
||||||
console.log("Adding to selection");
|
|
||||||
onSelectionChange([...selectedFiles, file]);
|
onSelectionChange([...selectedFiles, file]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single selection
|
|
||||||
console.log("Single selection - should select only:", file.name);
|
|
||||||
onSelectionChange([file]);
|
onSelectionChange([file]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click empty area to cancel selection
|
|
||||||
const handleGridClick = (event: React.MouseEvent) => {
|
const handleGridClick = (event: React.MouseEvent) => {
|
||||||
// Ensure grid gets focus to support keyboard events
|
|
||||||
if (gridRef.current) {
|
if (gridRef.current) {
|
||||||
gridRef.current.focus();
|
gridRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If just completed box selection, don't clear selection
|
|
||||||
if (
|
if (
|
||||||
event.target === event.currentTarget &&
|
event.target === event.currentTarget &&
|
||||||
!isSelecting &&
|
!isSelecting &&
|
||||||
@@ -846,10 +728,8 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard support
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Check if input box or editable element has focus, skip if so
|
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
if (
|
if (
|
||||||
activeElement &&
|
activeElement &&
|
||||||
@@ -868,7 +748,6 @@ export function FileManagerGrid({
|
|||||||
case "A":
|
case "A":
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("Ctrl+A pressed - selecting all files:", files.length);
|
|
||||||
onSelectionChange([...files]);
|
onSelectionChange([...files]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -910,7 +789,6 @@ export function FileManagerGrid({
|
|||||||
break;
|
break;
|
||||||
case "Delete":
|
case "Delete":
|
||||||
if (selectedFiles.length > 0 && onDelete) {
|
if (selectedFiles.length > 0 && onDelete) {
|
||||||
// Trigger delete operation
|
|
||||||
onDelete(selectedFiles);
|
onDelete(selectedFiles);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -922,7 +800,7 @@ export function FileManagerGrid({
|
|||||||
break;
|
break;
|
||||||
case "y":
|
case "y":
|
||||||
case "Y":
|
case "Y":
|
||||||
if ((event.ctrlKey || event.metaKey)) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onRefresh();
|
onRefresh();
|
||||||
}
|
}
|
||||||
@@ -957,9 +835,7 @@ export function FileManagerGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
<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">
|
<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">
|
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||||
<button
|
<button
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
@@ -1004,10 +880,8 @@ export function FileManagerGrid({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumb navigation */}
|
|
||||||
<div className="flex items-center px-3 py-2 text-sm">
|
<div className="flex items-center px-3 py-2 text-sm">
|
||||||
{isEditingPath ? (
|
{isEditingPath ? (
|
||||||
// Edit mode: path input box
|
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1038,7 +912,6 @@ export function FileManagerGrid({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// View mode: breadcrumb navigation
|
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateToPath(-1)}
|
onClick={() => navigateToPath(-1)}
|
||||||
@@ -1071,7 +944,6 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main file grid - scroll area */}
|
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
@@ -1092,7 +964,6 @@ export function FileManagerGrid({
|
|||||||
onContextMenu={(e) => onContextMenu?.(e)}
|
onContextMenu={(e) => onContextMenu?.(e)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{/* Drag hint overlay */}
|
|
||||||
{dragState.type === "external" && (
|
{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="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">
|
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
|
||||||
@@ -1128,7 +999,6 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : 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">
|
<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 && (
|
{createIntent && (
|
||||||
<CreateIntentGridItem
|
<CreateIntentGridItem
|
||||||
intent={createIntent}
|
intent={createIntent}
|
||||||
@@ -1169,10 +1039,8 @@ export function FileManagerGrid({
|
|||||||
onDragEnd={handleFileDragEnd}
|
onDragEnd={handleFileDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
{/* File icon */}
|
|
||||||
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
|
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
|
||||||
|
|
||||||
{/* File name */}
|
|
||||||
<div className="w-full flex flex-col items-center">
|
<div className="w-full flex flex-col items-center">
|
||||||
{editingFile?.path === file.path ? (
|
{editingFile?.path === file.path ? (
|
||||||
<input
|
<input
|
||||||
@@ -1221,7 +1089,6 @@ export function FileManagerGrid({
|
|||||||
) : (
|
) : (
|
||||||
/* List view */
|
/* List view */
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Linus-style creation intent UI - list view */}
|
|
||||||
{createIntent && (
|
{createIntent && (
|
||||||
<CreateIntentListItem
|
<CreateIntentListItem
|
||||||
intent={createIntent}
|
intent={createIntent}
|
||||||
@@ -1260,12 +1127,10 @@ export function FileManagerGrid({
|
|||||||
onDrop={(e) => handleFileDrop(e, file)}
|
onDrop={(e) => handleFileDrop(e, file)}
|
||||||
onDragEnd={handleFileDragEnd}
|
onDragEnd={handleFileDragEnd}
|
||||||
>
|
>
|
||||||
{/* File icon */}
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{getFileIcon(file, viewMode)}
|
{getFileIcon(file, viewMode)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{editingFile?.path === file.path ? (
|
{editingFile?.path === file.path ? (
|
||||||
<input
|
<input
|
||||||
@@ -1305,7 +1170,6 @@ export function FileManagerGrid({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File size */}
|
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="flex-shrink-0 text-right">
|
||||||
{file.type === "file" &&
|
{file.type === "file" &&
|
||||||
file.size !== undefined &&
|
file.size !== undefined &&
|
||||||
@@ -1316,7 +1180,6 @@ export function FileManagerGrid({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permission info */}
|
|
||||||
<div className="flex-shrink-0 text-right w-20">
|
<div className="flex-shrink-0 text-right w-20">
|
||||||
{file.permissions && (
|
{file.permissions && (
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
@@ -1330,7 +1193,6 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selection rectangle */}
|
|
||||||
{isSelecting && selectionRect && (
|
{isSelecting && selectionRect && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
|
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
|
||||||
@@ -1345,7 +1207,6 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
</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-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>{t("fileManager.itemCount", { count: files.length })}</span>
|
<span>{t("fileManager.itemCount", { count: files.length })}</span>
|
||||||
@@ -1357,7 +1218,6 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
|
|
||||||
{dragState.type === "internal" &&
|
{dragState.type === "internal" &&
|
||||||
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
|
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
|
||||||
dragState.mousePosition &&
|
dragState.mousePosition &&
|
||||||
@@ -1365,27 +1225,43 @@ export function FileManagerGrid({
|
|||||||
<div
|
<div
|
||||||
className="fixed pointer-events-none"
|
className="fixed pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: Math.min(Math.max(dragState.mousePosition.x + 40, 0), window.innerWidth - 300),
|
left: Math.min(
|
||||||
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 0),
|
Math.max(dragState.mousePosition.x + 40, 0),
|
||||||
|
window.innerWidth - 300,
|
||||||
|
),
|
||||||
|
top: Math.max(
|
||||||
|
Math.min(
|
||||||
|
dragState.mousePosition.y - 80,
|
||||||
|
window.innerHeight - 100,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
zIndex: 999999,
|
zIndex: 999999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || [];
|
const files =
|
||||||
|
dragState.files.length > 0
|
||||||
|
? dragState.files
|
||||||
|
: dragState.draggedFiles || [];
|
||||||
return dragState.target ? (
|
return dragState.target ? (
|
||||||
dragState.target.type === "directory" ? (
|
dragState.target.type === "directory" ? (
|
||||||
<>
|
<>
|
||||||
<Move className="w-4 h-4 text-blue-500" />
|
<Move className="w-4 h-4 text-blue-500" />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{t("fileManager.moveTo", { name: dragState.target.name })}
|
{t("fileManager.moveTo", {
|
||||||
|
name: dragState.target.name,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{t("fileManager.diffCompareWith", { name: dragState.target.name })}
|
{t("fileManager.diffCompareWith", {
|
||||||
|
name: dragState.target.name,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1393,20 +1269,21 @@ export function FileManagerGrid({
|
|||||||
<>
|
<>
|
||||||
<Download className="w-4 h-4 text-green-500" />
|
<Download className="w-4 h-4 text-green-500" />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{t("fileManager.dragOutsideToDownload", { count: files.length })}
|
{t("fileManager.dragOutsideToDownload", {
|
||||||
|
count: files.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linus-style creation intent component: Grid view
|
|
||||||
function CreateIntentGridItem({
|
function CreateIntentGridItem({
|
||||||
intent,
|
intent,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -1439,7 +1316,7 @@ function CreateIntentGridItem({
|
|||||||
<div className="group p-3 rounded-lg border-2 border-dashed border-primary bg-primary/10">
|
<div className="group p-3 rounded-lg border-2 border-dashed border-primary bg-primary/10">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{intent.type === 'directory' ? (
|
{intent.type === "directory" ? (
|
||||||
<Folder className="w-8 h-8 text-primary" />
|
<Folder className="w-8 h-8 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<File className="w-8 h-8 text-primary" />
|
<File className="w-8 h-8 text-primary" />
|
||||||
@@ -1453,14 +1330,17 @@ function CreateIntentGridItem({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={() => onConfirm?.(inputName.trim())}
|
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"
|
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')}
|
placeholder={
|
||||||
|
intent.type === "directory"
|
||||||
|
? t("fileManager.folderName")
|
||||||
|
: t("fileManager.fileName")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linus-style creation intent component: List view
|
|
||||||
function CreateIntentListItem({
|
function CreateIntentListItem({
|
||||||
intent,
|
intent,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -1492,7 +1372,7 @@ function CreateIntentListItem({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10">
|
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{intent.type === 'directory' ? (
|
{intent.type === "directory" ? (
|
||||||
<Folder className="w-6 h-6 text-primary" />
|
<Folder className="w-6 h-6 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<File className="w-6 h-6 text-primary" />
|
<File className="w-6 h-6 text-primary" />
|
||||||
@@ -1506,7 +1386,11 @@ function CreateIntentListItem({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={() => onConfirm?.(inputName.trim())}
|
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"
|
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')}
|
placeholder={
|
||||||
|
intent.type === "directory"
|
||||||
|
? t("fileManager.folderName")
|
||||||
|
: t("fileManager.fileName")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ interface FileManagerSidebarProps {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
onPathChange: (path: string) => void;
|
onPathChange: (path: string) => void;
|
||||||
onLoadDirectory?: (path: string) => void;
|
onLoadDirectory?: (path: string) => void;
|
||||||
onFileOpen?: (file: SidebarItem) => void; // Added: handle file opening
|
onFileOpen?: (file: SidebarItem) => void;
|
||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
refreshTrigger?: number; // Used to trigger data refresh
|
refreshTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerSidebar({
|
export function FileManagerSidebar({
|
||||||
@@ -61,7 +61,6 @@ export function FileManagerSidebar({
|
|||||||
new Set(["root"]),
|
new Set(["root"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Right-click menu state
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -74,12 +73,10 @@ export function FileManagerSidebar({
|
|||||||
item: null,
|
item: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load quick access data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuickAccessData();
|
loadQuickAccessData();
|
||||||
}, [currentHost, refreshTrigger]);
|
}, [currentHost, refreshTrigger]);
|
||||||
|
|
||||||
// Load directory tree (depends on sshSessionId)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sshSessionId) {
|
if (sshSessionId) {
|
||||||
loadDirectoryTree();
|
loadDirectoryTree();
|
||||||
@@ -90,7 +87,6 @@ export function FileManagerSidebar({
|
|||||||
if (!currentHost?.id) return;
|
if (!currentHost?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load recent files (limit to 5)
|
|
||||||
const recentData = await getRecentFiles(currentHost.id);
|
const recentData = await getRecentFiles(currentHost.id);
|
||||||
const recentItems = recentData.slice(0, 5).map((item: any) => ({
|
const recentItems = recentData.slice(0, 5).map((item: any) => ({
|
||||||
id: `recent-${item.id}`,
|
id: `recent-${item.id}`,
|
||||||
@@ -101,7 +97,6 @@ export function FileManagerSidebar({
|
|||||||
}));
|
}));
|
||||||
setRecentItems(recentItems);
|
setRecentItems(recentItems);
|
||||||
|
|
||||||
// Load pinned files
|
|
||||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||||
const pinnedItems = pinnedData.map((item: any) => ({
|
const pinnedItems = pinnedData.map((item: any) => ({
|
||||||
id: `pinned-${item.id}`,
|
id: `pinned-${item.id}`,
|
||||||
@@ -111,7 +106,6 @@ export function FileManagerSidebar({
|
|||||||
}));
|
}));
|
||||||
setPinnedItems(pinnedItems);
|
setPinnedItems(pinnedItems);
|
||||||
|
|
||||||
// Load folder shortcuts
|
|
||||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||||
const shortcutItems = shortcutData.map((item: any) => ({
|
const shortcutItems = shortcutData.map((item: any) => ({
|
||||||
id: `shortcut-${item.id}`,
|
id: `shortcut-${item.id}`,
|
||||||
@@ -122,20 +116,18 @@ export function FileManagerSidebar({
|
|||||||
setShortcuts(shortcutItems);
|
setShortcuts(shortcutItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load quick access data:", error);
|
console.error("Failed to load quick access data:", error);
|
||||||
// If loading fails, keep empty arrays
|
|
||||||
setRecentItems([]);
|
setRecentItems([]);
|
||||||
setPinnedItems([]);
|
setPinnedItems([]);
|
||||||
setShortcuts([]);
|
setShortcuts([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete functionality implementation
|
|
||||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||||
if (!currentHost?.id) return;
|
if (!currentHost?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeRecentFile(currentHost.id, item.path);
|
await removeRecentFile(currentHost.id, item.path);
|
||||||
loadQuickAccessData(); // Reload data
|
loadQuickAccessData();
|
||||||
toast.success(
|
toast.success(
|
||||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||||
);
|
);
|
||||||
@@ -150,7 +142,7 @@ export function FileManagerSidebar({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await removePinnedFile(currentHost.id, item.path);
|
await removePinnedFile(currentHost.id, item.path);
|
||||||
loadQuickAccessData(); // Reload data
|
loadQuickAccessData();
|
||||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to unpin file:", error);
|
console.error("Failed to unpin file:", error);
|
||||||
@@ -163,7 +155,7 @@ export function FileManagerSidebar({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await removeFolderShortcut(currentHost.id, item.path);
|
await removeFolderShortcut(currentHost.id, item.path);
|
||||||
loadQuickAccessData(); // Reload data
|
loadQuickAccessData();
|
||||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remove shortcut:", error);
|
console.error("Failed to remove shortcut:", error);
|
||||||
@@ -175,11 +167,10 @@ export function FileManagerSidebar({
|
|||||||
if (!currentHost?.id || recentItems.length === 0) return;
|
if (!currentHost?.id || recentItems.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Batch delete all recent files
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||||
);
|
);
|
||||||
loadQuickAccessData(); // Reload data
|
loadQuickAccessData();
|
||||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to clear recent files:", error);
|
console.error("Failed to clear recent files:", error);
|
||||||
@@ -187,7 +178,6 @@ export function FileManagerSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Right-click menu handling
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -204,7 +194,6 @@ export function FileManagerSidebar({
|
|||||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click outside to close menu
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contextMenu.isVisible) return;
|
if (!contextMenu.isVisible) return;
|
||||||
|
|
||||||
@@ -223,7 +212,6 @@ export function FileManagerSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delay adding listeners to avoid immediate trigger
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -240,10 +228,8 @@ export function FileManagerSidebar({
|
|||||||
if (!sshSessionId) return;
|
if (!sshSessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load root directory
|
|
||||||
const response = await listSSHFiles(sshSessionId, "/");
|
const response = await listSSHFiles(sshSessionId, "/");
|
||||||
|
|
||||||
// listSSHFiles now always returns {files: Array, path: string} format
|
|
||||||
const rootFiles = response.files || [];
|
const rootFiles = response.files || [];
|
||||||
const rootFolders = rootFiles.filter(
|
const rootFolders = rootFiles.filter(
|
||||||
(item: any) => item.type === "directory",
|
(item: any) => item.type === "directory",
|
||||||
@@ -255,7 +241,7 @@ export function FileManagerSidebar({
|
|||||||
path: folder.path,
|
path: folder.path,
|
||||||
type: "folder" as const,
|
type: "folder" as const,
|
||||||
isExpanded: false,
|
isExpanded: false,
|
||||||
children: [], // Subdirectories will be loaded on demand
|
children: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setDirectoryTree([
|
setDirectoryTree([
|
||||||
@@ -270,7 +256,6 @@ export function FileManagerSidebar({
|
|||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load directory tree:", error);
|
console.error("Failed to load directory tree:", error);
|
||||||
// If loading fails, show simple root directory
|
|
||||||
setDirectoryTree([
|
setDirectoryTree([
|
||||||
{
|
{
|
||||||
id: "root",
|
id: "root",
|
||||||
@@ -289,17 +274,14 @@ export function FileManagerSidebar({
|
|||||||
toggleFolder(item.id, item.path);
|
toggleFolder(item.id, item.path);
|
||||||
onPathChange(item.path);
|
onPathChange(item.path);
|
||||||
} else if (item.type === "recent" || item.type === "pinned") {
|
} else if (item.type === "recent" || item.type === "pinned") {
|
||||||
// For file types, call file open callback
|
|
||||||
if (onFileOpen) {
|
if (onFileOpen) {
|
||||||
onFileOpen(item);
|
onFileOpen(item);
|
||||||
} else {
|
} else {
|
||||||
// If no file open callback, switch to file directory
|
|
||||||
const directory =
|
const directory =
|
||||||
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||||
onPathChange(directory);
|
onPathChange(directory);
|
||||||
}
|
}
|
||||||
} else if (item.type === "shortcut") {
|
} else if (item.type === "shortcut") {
|
||||||
// Folder shortcuts directly switch to directory
|
|
||||||
onPathChange(item.path);
|
onPathChange(item.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -312,12 +294,10 @@ export function FileManagerSidebar({
|
|||||||
} else {
|
} else {
|
||||||
newExpanded.add(folderId);
|
newExpanded.add(folderId);
|
||||||
|
|
||||||
// Load subdirectories on demand
|
|
||||||
if (sshSessionId && folderPath && folderPath !== "/") {
|
if (sshSessionId && folderPath && folderPath !== "/") {
|
||||||
try {
|
try {
|
||||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||||
|
|
||||||
// listSSHFiles now always returns {files: Array, path: string} format
|
|
||||||
const subFiles = subResponse.files || [];
|
const subFiles = subResponse.files || [];
|
||||||
const subFolders = subFiles.filter(
|
const subFolders = subFiles.filter(
|
||||||
(item: any) => item.type === "directory",
|
(item: any) => item.type === "directory",
|
||||||
@@ -332,7 +312,6 @@ export function FileManagerSidebar({
|
|||||||
children: [],
|
children: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update directory tree, add subdirectories for current folder
|
|
||||||
setDirectoryTree((prevTree) => {
|
setDirectoryTree((prevTree) => {
|
||||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
|
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
@@ -370,7 +349,6 @@ export function FileManagerSidebar({
|
|||||||
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
|
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
|
||||||
onClick={() => handleItemClick(item)}
|
onClick={() => handleItemClick(item)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
// Only quick access items need right-click menu
|
|
||||||
if (
|
if (
|
||||||
item.type === "recent" ||
|
item.type === "recent" ||
|
||||||
item.type === "pinned" ||
|
item.type === "pinned" ||
|
||||||
@@ -438,7 +416,6 @@ export function FileManagerSidebar({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if there are any quick access items
|
|
||||||
const hasQuickAccessItems =
|
const hasQuickAccessItems =
|
||||||
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
|
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
|
||||||
|
|
||||||
@@ -447,7 +424,6 @@ export function FileManagerSidebar({
|
|||||||
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
|
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
|
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
|
||||||
{/* Quick access area */}
|
|
||||||
{renderSection(
|
{renderSection(
|
||||||
t("fileManager.recent"),
|
t("fileManager.recent"),
|
||||||
<Clock className="w-3 h-3" />,
|
<Clock className="w-3 h-3" />,
|
||||||
@@ -464,7 +440,6 @@ export function FileManagerSidebar({
|
|||||||
shortcuts,
|
shortcuts,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Directory tree */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
hasQuickAccessItems && "pt-4 border-t border-dark-border",
|
hasQuickAccessItems && "pt-4 border-t border-dark-border",
|
||||||
@@ -482,7 +457,6 @@ export function FileManagerSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right-click menu */}
|
|
||||||
{contextMenu.isVisible && contextMenu.item && (
|
{contextMenu.isVisible && contextMenu.item && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" />
|
<div className="fixed inset-0 z-40" />
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export function DiffViewer({
|
|||||||
file2,
|
file2,
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
sshHost,
|
sshHost,
|
||||||
onDownload1,
|
|
||||||
onDownload2,
|
|
||||||
}: DiffViewerProps) {
|
}: DiffViewerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [content1, setContent1] = useState<string>("");
|
const [content1, setContent1] = useState<string>("");
|
||||||
@@ -46,7 +44,6 @@ export function DiffViewer({
|
|||||||
);
|
);
|
||||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||||
|
|
||||||
// Ensure SSH connection is valid
|
|
||||||
const ensureSSHConnection = async () => {
|
const ensureSSHConnection = async () => {
|
||||||
try {
|
try {
|
||||||
const status = await getSSHStatus(sshSessionId);
|
const status = await getSSHStatus(sshSessionId);
|
||||||
@@ -70,7 +67,6 @@ export function DiffViewer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load file contents
|
|
||||||
const loadFileContents = async () => {
|
const loadFileContents = async () => {
|
||||||
if (file1.type !== "file" || file2.type !== "file") {
|
if (file1.type !== "file" || file2.type !== "file") {
|
||||||
setError(t("fileManager.canOnlyCompareFiles"));
|
setError(t("fileManager.canOnlyCompareFiles"));
|
||||||
@@ -81,10 +77,8 @@ export function DiffViewer({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Ensure SSH connection is valid
|
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
// Load both files in parallel
|
|
||||||
const [response1, response2] = await Promise.all([
|
const [response1, response2] = await Promise.all([
|
||||||
readSSHFile(sshSessionId, file1.path),
|
readSSHFile(sshSessionId, file1.path),
|
||||||
readSSHFile(sshSessionId, file2.path),
|
readSSHFile(sshSessionId, file2.path),
|
||||||
@@ -106,13 +100,16 @@ export function DiffViewer({
|
|||||||
t("fileManager.sshConnectionFailed", {
|
t("fileManager.sshConnectionFailed", {
|
||||||
name: sshHost.name,
|
name: sshHost.name,
|
||||||
ip: sshHost.ip,
|
ip: sshHost.ip,
|
||||||
port: sshHost.port
|
port: sshHost.port,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError(
|
setError(
|
||||||
t("fileManager.loadFileFailed", {
|
t("fileManager.loadFileFailed", {
|
||||||
error: error.message || errorData?.error || t("fileManager.unknownError")
|
error:
|
||||||
|
error.message ||
|
||||||
|
errorData?.error ||
|
||||||
|
t("fileManager.unknownError"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,7 +118,6 @@ export function DiffViewer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download file
|
|
||||||
const handleDownloadFile = async (file: FileItem) => {
|
const handleDownloadFile = async (file: FileItem) => {
|
||||||
try {
|
try {
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
@@ -147,15 +143,20 @@ export function DiffViewer({
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }));
|
toast.success(
|
||||||
|
t("fileManager.downloadFileSuccess", { name: file.name }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to download file:", error);
|
console.error("Failed to download file:", error);
|
||||||
toast.error(t("fileManager.downloadFileFailed") + ": " + (error.message || t("fileManager.unknownError")));
|
toast.error(
|
||||||
|
t("fileManager.downloadFileFailed") +
|
||||||
|
": " +
|
||||||
|
(error.message || t("fileManager.unknownError")),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get file language type
|
|
||||||
const getFileLanguage = (fileName: string): string => {
|
const getFileLanguage = (fileName: string): string => {
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
const languageMap: Record<string, string> = {
|
const languageMap: Record<string, string> = {
|
||||||
@@ -190,7 +191,6 @@ export function DiffViewer({
|
|||||||
return languageMap[ext || ""] || "plaintext";
|
return languageMap[ext || ""] || "plaintext";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFileContents();
|
loadFileContents();
|
||||||
}, [file1, file2, sshSessionId]);
|
}, [file1, file2, sshSessionId]);
|
||||||
@@ -200,7 +200,9 @@ export function DiffViewer({
|
|||||||
<div className="h-full flex items-center justify-center bg-dark-bg">
|
<div className="h-full flex items-center justify-center bg-dark-bg">
|
||||||
<div className="text-center">
|
<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>
|
<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">{t("fileManager.loadingFileComparison")}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("fileManager.loadingFileComparison")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -223,12 +225,13 @@ export function DiffViewer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-bg">
|
<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-shrink-0 border-b border-dark-border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-muted-foreground">{t("fileManager.compare")}:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{t("fileManager.compare")}:
|
||||||
|
</span>
|
||||||
<span className="font-medium text-green-400 mx-2">
|
<span className="font-medium text-green-400 mx-2">
|
||||||
{file1.name}
|
{file1.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -238,7 +241,6 @@ export function DiffViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View toggle */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -248,10 +250,11 @@ export function DiffViewer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{diffMode === "side-by-side" ? t("fileManager.sideBySide") : t("fileManager.inline")}
|
{diffMode === "side-by-side"
|
||||||
|
? t("fileManager.sideBySide")
|
||||||
|
: t("fileManager.inline")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Line number toggle */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -264,7 +267,6 @@ export function DiffViewer({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Download buttons */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -285,7 +287,6 @@ export function DiffViewer({
|
|||||||
{file2.name}
|
{file2.name}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Refresh button */}
|
|
||||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -293,7 +294,6 @@ export function DiffViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diff editor */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DiffEditor
|
<DiffEditor
|
||||||
original={content1}
|
original={content1}
|
||||||
@@ -322,7 +322,9 @@ export function DiffViewer({
|
|||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-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>
|
<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">{t("fileManager.initializingEditor")}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("fileManager.initializingEditor")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export function DiffWindow({
|
|||||||
|
|
||||||
const currentWindow = windows.find((w) => w.id === windowId);
|
const currentWindow = windows.find((w) => w.id === windowId);
|
||||||
|
|
||||||
// Window operation handling
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
closeWindow(windowId);
|
closeWindow(windowId);
|
||||||
};
|
};
|
||||||
@@ -49,7 +48,10 @@ export function DiffWindow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableWindow
|
<DraggableWindow
|
||||||
title={t("fileManager.fileComparison", { file1: file1.name, file2: file2.name })}
|
title={t("fileManager.fileComparison", {
|
||||||
|
file1: file1.name,
|
||||||
|
file2: file2.name,
|
||||||
|
})}
|
||||||
initialX={initialX}
|
initialX={initialX}
|
||||||
initialY={initialY}
|
initialY={initialY}
|
||||||
initialWidth={1200}
|
initialWidth={1200}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export function DraggableWindow({
|
|||||||
targetSize,
|
targetSize,
|
||||||
}: DraggableWindowProps) {
|
}: DraggableWindowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Window state
|
|
||||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||||
const [size, setSize] = useState({
|
const [size, setSize] = useState({
|
||||||
width: initialWidth,
|
width: initialWidth,
|
||||||
@@ -49,7 +48,6 @@ export function DraggableWindow({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||||
|
|
||||||
// Drag and resize start positions
|
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||||
@@ -57,17 +55,14 @@ export function DraggableWindow({
|
|||||||
const windowRef = useRef<HTMLDivElement>(null);
|
const windowRef = useRef<HTMLDivElement>(null);
|
||||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Handle target size changes for media files
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetSize && !isMaximized) {
|
if (targetSize && !isMaximized) {
|
||||||
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
||||||
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
||||||
|
|
||||||
// Calculate appropriate window size maintaining aspect ratio
|
let newWidth = Math.min(targetSize.width + 50, maxWidth);
|
||||||
let newWidth = Math.min(targetSize.width + 50, maxWidth); // Add padding for UI
|
let newHeight = Math.min(targetSize.height + 150, maxHeight);
|
||||||
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) {
|
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||||
const widthRatio = maxWidth / newWidth;
|
const widthRatio = maxWidth / newWidth;
|
||||||
const heightRatio = maxHeight / newHeight;
|
const heightRatio = maxHeight / newHeight;
|
||||||
@@ -77,26 +72,22 @@ export function DraggableWindow({
|
|||||||
newHeight = Math.floor(newHeight * scale);
|
newHeight = Math.floor(newHeight * scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure minimum size
|
|
||||||
newWidth = Math.max(newWidth, minWidth);
|
newWidth = Math.max(newWidth, minWidth);
|
||||||
newHeight = Math.max(newHeight, minHeight);
|
newHeight = Math.max(newHeight, minHeight);
|
||||||
|
|
||||||
setSize({ width: newWidth, height: newHeight });
|
setSize({ width: newWidth, height: newHeight });
|
||||||
|
|
||||||
// Center the window
|
|
||||||
setPosition({
|
setPosition({
|
||||||
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
||||||
y: Math.max(0, (window.innerHeight - newHeight) / 2)
|
y: Math.max(0, (window.innerHeight - newHeight) / 2),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||||
|
|
||||||
// Handle window focus
|
|
||||||
const handleWindowClick = useCallback(() => {
|
const handleWindowClick = useCallback(() => {
|
||||||
onFocus?.();
|
onFocus?.();
|
||||||
}, [onFocus]);
|
}, [onFocus]);
|
||||||
|
|
||||||
// Drag handling
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isMaximized) return;
|
if (isMaximized) return;
|
||||||
@@ -119,7 +110,6 @@ export function DraggableWindow({
|
|||||||
const newX = windowStart.x + deltaX;
|
const newX = windowStart.x + deltaX;
|
||||||
const newY = windowStart.y + deltaY;
|
const newY = windowStart.y + deltaY;
|
||||||
|
|
||||||
// Find the positioning container by checking parent hierarchy
|
|
||||||
const windowElement = windowRef.current;
|
const windowElement = windowRef.current;
|
||||||
let positioningContainer = null;
|
let positioningContainer = null;
|
||||||
let currentElement = windowElement?.parentElement;
|
let currentElement = windowElement?.parentElement;
|
||||||
@@ -129,7 +119,12 @@ export function DraggableWindow({
|
|||||||
const position = computedStyle.position;
|
const position = computedStyle.position;
|
||||||
const transform = computedStyle.transform;
|
const transform = computedStyle.transform;
|
||||||
|
|
||||||
if (position === 'relative' || position === 'absolute' || position === 'fixed' || transform !== 'none') {
|
if (
|
||||||
|
position === "relative" ||
|
||||||
|
position === "absolute" ||
|
||||||
|
position === "fixed" ||
|
||||||
|
transform !== "none"
|
||||||
|
) {
|
||||||
positioningContainer = currentElement;
|
positioningContainer = currentElement;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -137,26 +132,22 @@ export function DraggableWindow({
|
|||||||
currentElement = currentElement.parentElement;
|
currentElement = currentElement.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate boundaries based on the actual positioning context
|
|
||||||
let maxX, maxY, minX, minY;
|
let maxX, maxY, minX, minY;
|
||||||
|
|
||||||
if (positioningContainer) {
|
if (positioningContainer) {
|
||||||
const containerRect = positioningContainer.getBoundingClientRect();
|
const containerRect = positioningContainer.getBoundingClientRect();
|
||||||
|
|
||||||
// Window is positioned relative to a positioning container
|
|
||||||
maxX = containerRect.width - size.width;
|
maxX = containerRect.width - size.width;
|
||||||
maxY = containerRect.height - size.height;
|
maxY = containerRect.height - size.height;
|
||||||
minX = 0;
|
minX = 0;
|
||||||
minY = 0;
|
minY = 0;
|
||||||
} else {
|
} else {
|
||||||
// Window is positioned relative to viewport
|
|
||||||
maxX = window.innerWidth - size.width;
|
maxX = window.innerWidth - size.width;
|
||||||
maxY = window.innerHeight - size.height;
|
maxY = window.innerHeight - size.height;
|
||||||
minX = 0;
|
minX = 0;
|
||||||
minY = 0;
|
minY = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure window stays within boundaries
|
|
||||||
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
||||||
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
||||||
|
|
||||||
@@ -175,14 +166,12 @@ export function DraggableWindow({
|
|||||||
let newX = windowStart.x;
|
let newX = windowStart.x;
|
||||||
let newY = windowStart.y;
|
let newY = windowStart.y;
|
||||||
|
|
||||||
// Handle horizontal resizing
|
|
||||||
if (resizeDirection.includes("right")) {
|
if (resizeDirection.includes("right")) {
|
||||||
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
||||||
}
|
}
|
||||||
if (resizeDirection.includes("left")) {
|
if (resizeDirection.includes("left")) {
|
||||||
const widthChange = -deltaX;
|
const widthChange = -deltaX;
|
||||||
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
||||||
// Only move position if we're actually changing size
|
|
||||||
if (newWidth > minWidth || widthChange > 0) {
|
if (newWidth > minWidth || widthChange > 0) {
|
||||||
newX = windowStart.x - (newWidth - sizeStart.width);
|
newX = windowStart.x - (newWidth - sizeStart.width);
|
||||||
} else {
|
} else {
|
||||||
@@ -190,14 +179,12 @@ export function DraggableWindow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle vertical resizing
|
|
||||||
if (resizeDirection.includes("bottom")) {
|
if (resizeDirection.includes("bottom")) {
|
||||||
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
||||||
}
|
}
|
||||||
if (resizeDirection.includes("top")) {
|
if (resizeDirection.includes("top")) {
|
||||||
const heightChange = -deltaY;
|
const heightChange = -deltaY;
|
||||||
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
||||||
// Only move position if we're actually changing size
|
|
||||||
if (newHeight > minHeight || heightChange > 0) {
|
if (newHeight > minHeight || heightChange > 0) {
|
||||||
newY = windowStart.y - (newHeight - sizeStart.height);
|
newY = windowStart.y - (newHeight - sizeStart.height);
|
||||||
} else {
|
} else {
|
||||||
@@ -205,7 +192,6 @@ export function DraggableWindow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure window stays within viewport
|
|
||||||
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
||||||
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
||||||
|
|
||||||
@@ -234,7 +220,6 @@ export function DraggableWindow({
|
|||||||
setResizeDirection("");
|
setResizeDirection("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resize handling
|
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeStart = useCallback(
|
||||||
(e: React.MouseEvent, direction: string) => {
|
(e: React.MouseEvent, direction: string) => {
|
||||||
if (isMaximized) return;
|
if (isMaximized) return;
|
||||||
@@ -251,7 +236,6 @@ export function DraggableWindow({
|
|||||||
[isMaximized, position, size, onFocus],
|
[isMaximized, position, size, onFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global event listeners
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging || isResizing) {
|
if (isDragging || isResizing) {
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
@@ -268,7 +252,6 @@ export function DraggableWindow({
|
|||||||
}
|
}
|
||||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
// Double-click title bar to maximize/restore
|
|
||||||
const handleTitleDoubleClick = useCallback(() => {
|
const handleTitleDoubleClick = useCallback(() => {
|
||||||
onMaximize?.();
|
onMaximize?.();
|
||||||
}, [onMaximize]);
|
}, [onMaximize]);
|
||||||
@@ -290,7 +273,6 @@ export function DraggableWindow({
|
|||||||
}}
|
}}
|
||||||
onClick={handleWindowClick}
|
onClick={handleWindowClick}
|
||||||
>
|
>
|
||||||
{/* Title bar */}
|
|
||||||
<div
|
<div
|
||||||
ref={titleBarRef}
|
ref={titleBarRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -349,7 +331,6 @@ export function DraggableWindow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Window content */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-auto"
|
className="flex-1 overflow-auto"
|
||||||
style={{ height: "calc(100% - 40px)" }}
|
style={{ height: "calc(100% - 40px)" }}
|
||||||
@@ -357,10 +338,8 @@ export function DraggableWindow({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize borders - only show when not maximized */}
|
|
||||||
{!isMaximized && (
|
{!isMaximized && (
|
||||||
<>
|
<>
|
||||||
{/* Edge resize */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||||
@@ -378,7 +357,6 @@ export function DraggableWindow({
|
|||||||
onMouseDown={(e) => handleResizeStart(e, "right")}
|
onMouseDown={(e) => handleResizeStart(e, "right")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Corner resize */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ import { oneDark } from "@codemirror/theme-one-dark";
|
|||||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||||
import { EditorView, keymap } from "@codemirror/view";
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||||
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
|
import {
|
||||||
|
defaultKeymap,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
toggleComment,
|
||||||
|
} from "@codemirror/commands";
|
||||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||||
import "react-photo-view/dist/react-photo-view.css";
|
import "react-photo-view/dist/react-photo-view.css";
|
||||||
@@ -64,8 +69,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|||||||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { Document, Page, pdfjs } from "react-pdf";
|
import { Document, Page, pdfjs } from "react-pdf";
|
||||||
|
|
||||||
// Use local PDF.js worker to avoid CDN issues
|
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,15 +91,16 @@ interface FileViewerProps {
|
|||||||
onContentChange?: (content: string) => void;
|
onContentChange?: (content: string) => void;
|
||||||
onSave?: (content: string) => void;
|
onSave?: (content: string) => void;
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
onMediaDimensionsChange?: (dimensions: { width: number; height: number }) => void;
|
onMediaDimensionsChange?: (dimensions: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get official icon for programming languages
|
|
||||||
function getLanguageIcon(filename: string): React.ReactNode {
|
function getLanguageIcon(filename: string): React.ReactNode {
|
||||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||||
const baseName = filename.toLowerCase();
|
const baseName = filename.toLowerCase();
|
||||||
|
|
||||||
// Special filename handling
|
|
||||||
if (["dockerfile"].includes(baseName)) {
|
if (["dockerfile"].includes(baseName)) {
|
||||||
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||||||
}
|
}
|
||||||
@@ -141,7 +146,6 @@ function getLanguageIcon(filename: string): React.ReactNode {
|
|||||||
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file type and icon
|
|
||||||
function getFileType(filename: string): {
|
function getFileType(filename: string): {
|
||||||
type: string;
|
type: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -239,17 +243,14 @@ function getFileType(filename: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get CodeMirror language extension
|
|
||||||
function getLanguageExtension(filename: string) {
|
function getLanguageExtension(filename: string) {
|
||||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||||
const baseName = filename.toLowerCase();
|
const baseName = filename.toLowerCase();
|
||||||
|
|
||||||
// Special filename handling
|
|
||||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||||
return loadLanguage(baseName);
|
return loadLanguage(baseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map by file extension
|
|
||||||
const langMap: Record<string, string> = {
|
const langMap: Record<string, string> = {
|
||||||
js: "javascript",
|
js: "javascript",
|
||||||
jsx: "jsx",
|
jsx: "jsx",
|
||||||
@@ -288,7 +289,6 @@ function getLanguageExtension(filename: string) {
|
|||||||
return language ? loadLanguage(language) : null;
|
return language ? loadLanguage(language) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size
|
|
||||||
function formatFileSize(bytes?: number, t?: any): string {
|
function formatFileSize(bytes?: number, t?: any): string {
|
||||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
@@ -328,32 +328,25 @@ export function FileViewer({
|
|||||||
|
|
||||||
const fileTypeInfo = getFileType(file.name);
|
const fileTypeInfo = getFileType(file.name);
|
||||||
|
|
||||||
// File size limits - remove hard limits, support large file handling
|
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||||
const WARNING_SIZE = 50 * 1024 * 1024; // 50MB warning
|
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER; // Remove hard limits
|
|
||||||
|
|
||||||
// Check if should display as text
|
|
||||||
const shouldShowAsText =
|
const shouldShowAsText =
|
||||||
fileTypeInfo.type === "text" ||
|
fileTypeInfo.type === "text" ||
|
||||||
fileTypeInfo.type === "code" ||
|
fileTypeInfo.type === "code" ||
|
||||||
(fileTypeInfo.type === "unknown" &&
|
(fileTypeInfo.type === "unknown" &&
|
||||||
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||||
|
|
||||||
// Check if file is too large
|
|
||||||
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||||||
const isTooLarge = file.size && file.size > MAX_SIZE;
|
const isTooLarge = file.size && file.size > MAX_SIZE;
|
||||||
|
|
||||||
// Sync external content changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditedContent(content);
|
setEditedContent(content);
|
||||||
// Only update originalContent when savedContent is updated
|
|
||||||
if (savedContent) {
|
if (savedContent) {
|
||||||
setOriginalContent(savedContent);
|
setOriginalContent(savedContent);
|
||||||
}
|
}
|
||||||
// Fix: Compare current content with saved content properly
|
|
||||||
setHasChanges(content !== savedContent);
|
setHasChanges(content !== savedContent);
|
||||||
|
|
||||||
// If unknown file type and file is large, show warning
|
|
||||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||||
setShowLargeFileWarning(true);
|
setShowLargeFileWarning(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -361,59 +354,46 @@ export function FileViewer({
|
|||||||
}
|
}
|
||||||
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||||
|
|
||||||
// Handle content changes
|
|
||||||
const handleContentChange = (newContent: string) => {
|
const handleContentChange = (newContent: string) => {
|
||||||
setEditedContent(newContent);
|
setEditedContent(newContent);
|
||||||
// Fix: Compare with savedContent instead of originalContent for consistency
|
|
||||||
setHasChanges(newContent !== savedContent);
|
setHasChanges(newContent !== savedContent);
|
||||||
onContentChange?.(newContent);
|
onContentChange?.(newContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save file
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave?.(editedContent);
|
onSave?.(editedContent);
|
||||||
// Note: Don't update originalContent here, as it will be updated via savedContent prop
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Revert file
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
setEditedContent(savedContent);
|
setEditedContent(savedContent);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
onContentChange?.(savedContent);
|
onContentChange?.(savedContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle save shortcut specifically
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorFocused || !isEditable) return;
|
if (!editorFocused || !isEditable) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Only handle Ctrl+S for custom save, let CodeMirror handle everything else
|
|
||||||
const isCtrl = e.ctrlKey || e.metaKey;
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
if (isCtrl && e.key.toLowerCase() === 's') {
|
if (isCtrl && e.key.toLowerCase() === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSave();
|
handleSave();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listener with capture for save shortcut only
|
document.addEventListener("keydown", handleKeyDown, true);
|
||||||
document.addEventListener('keydown', handleKeyDown, true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown, true);
|
document.removeEventListener("keydown", handleKeyDown, true);
|
||||||
};
|
};
|
||||||
}, [editorFocused, isEditable, handleSave]);
|
}, [editorFocused, isEditable, handleSave]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Handle user confirmation to open large file
|
|
||||||
const handleConfirmOpenAsText = () => {
|
const handleConfirmOpenAsText = () => {
|
||||||
setForceShowAsText(true);
|
setForceShowAsText(true);
|
||||||
setShowLargeFileWarning(false);
|
setShowLargeFileWarning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle user rejection to open large file
|
|
||||||
const handleCancelOpenAsText = () => {
|
const handleCancelOpenAsText = () => {
|
||||||
setShowLargeFileWarning(false);
|
setShowLargeFileWarning(false);
|
||||||
};
|
};
|
||||||
@@ -431,7 +411,6 @@ export function FileViewer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-background">
|
<div className="h-full flex flex-col bg-background">
|
||||||
{/* File info header */}
|
|
||||||
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -442,7 +421,11 @@ export function FileViewer({
|
|||||||
<h3 className="font-medium text-foreground">{file.name}</h3>
|
<h3 className="font-medium text-foreground">{file.name}</h3>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span>{formatFileSize(file.size, t)}</span>
|
<span>{formatFileSize(file.size, t)}</span>
|
||||||
{file.modified && <span>{t("fileManager.modified")}: {file.modified}</span>}
|
{file.modified && (
|
||||||
|
<span>
|
||||||
|
{t("fileManager.modified")}: {file.modified}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1 rounded-full text-xs",
|
"px-2 py-1 rounded-full text-xs",
|
||||||
@@ -457,13 +440,11 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Search button */}
|
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Use CodeMirror's proper API to open search panel
|
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
const view = editorRef.current.view;
|
const view = editorRef.current.view;
|
||||||
if (view) {
|
if (view) {
|
||||||
@@ -477,7 +458,6 @@ export function FileViewer({
|
|||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Keyboard shortcuts help */}
|
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -526,11 +506,12 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard shortcuts help panel */}
|
|
||||||
{showKeyboardShortcuts && isEditable && (
|
{showKeyboardShortcuts && isEditable && (
|
||||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold">{t("fileManager.keyboardShortcuts")}</h3>
|
<h3 className="text-sm font-semibold">
|
||||||
|
{t("fileManager.keyboardShortcuts")}
|
||||||
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -542,60 +523,88 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.searchAndReplace")}</h4>
|
<h4 className="font-medium text-muted-foreground">
|
||||||
|
{t("fileManager.searchAndReplace")}
|
||||||
|
</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.search")}</span>
|
<span>{t("fileManager.search")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+F</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+F
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.replace")}</span>
|
<span>{t("fileManager.replace")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+H</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+H
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.findNext")}</span>
|
<span>{t("fileManager.findNext")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">F3</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
F3
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.findPrevious")}</span>
|
<span>{t("fileManager.findPrevious")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+F3</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Shift+F3
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.editing")}</h4>
|
<h4 className="font-medium text-muted-foreground">
|
||||||
|
{t("fileManager.editing")}
|
||||||
|
</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.save")}</span>
|
<span>{t("fileManager.save")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+S</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+S
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.selectAll")}</span>
|
<span>{t("fileManager.selectAll")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+A</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+A
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.undo")}</span>
|
<span>{t("fileManager.undo")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Z</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+Z
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.redo")}</span>
|
<span>{t("fileManager.redo")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+Y
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.toggleComment")}</span>
|
<span>{t("fileManager.toggleComment")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+/
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.autoComplete")}</span>
|
<span>{t("fileManager.autoComplete")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Ctrl+Space
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.moveLineUp")}</span>
|
<span>{t("fileManager.moveLineUp")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+↑</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Alt+↑
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.moveLineDown")}</span>
|
<span>{t("fileManager.moveLineDown")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+↓</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||||
|
Alt+↓
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,9 +612,7 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File content */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{/* Large file warning dialog */}
|
|
||||||
{showLargeFileWarning && (
|
{showLargeFileWarning && (
|
||||||
<div className="h-full flex items-center justify-center bg-background">
|
<div className="h-full flex items-center justify-center bg-background">
|
||||||
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
||||||
@@ -616,7 +623,9 @@ export function FileViewer({
|
|||||||
{t("fileManager.largeFileWarning")}
|
{t("fileManager.largeFileWarning")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
{t("fileManager.largeFileWarningDesc", { size: formatFileSize(file.size, t) })}
|
{t("fileManager.largeFileWarningDesc", {
|
||||||
|
size: formatFileSize(file.size, t),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
{isTooLarge ? (
|
{isTooLarge ? (
|
||||||
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||||||
@@ -667,19 +676,15 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image preview with react-photo-view */}
|
|
||||||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||||||
<div className="p-6 flex items-center justify-center h-full relative">
|
<div className="p-6 flex items-center justify-center h-full relative">
|
||||||
{imageLoadError ? (
|
{imageLoadError ? (
|
||||||
// Error state
|
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
<h3 className="text-lg font-medium mb-2">
|
<h3 className="text-lg font-medium mb-2">
|
||||||
{t("fileManager.imageLoadError")}
|
{t("fileManager.imageLoadError")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm mb-4">
|
<p className="text-sm mb-4">{file.name}</p>
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -703,12 +708,15 @@ export function FileViewer({
|
|||||||
setImageLoading(false);
|
setImageLoading(false);
|
||||||
setImageLoadError(false);
|
setImageLoadError(false);
|
||||||
|
|
||||||
// Get natural dimensions and notify parent
|
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
if (onMediaDimensionsChange && img.naturalWidth && img.naturalHeight) {
|
if (
|
||||||
|
onMediaDimensionsChange &&
|
||||||
|
img.naturalWidth &&
|
||||||
|
img.naturalHeight
|
||||||
|
) {
|
||||||
onMediaDimensionsChange({
|
onMediaDimensionsChange({
|
||||||
width: img.naturalWidth,
|
width: img.naturalWidth,
|
||||||
height: img.naturalHeight
|
height: img.naturalHeight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -721,23 +729,22 @@ export function FileViewer({
|
|||||||
</PhotoProvider>
|
</PhotoProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{imageLoading && !imageLoadError && (
|
{imageLoading && !imageLoadError && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||||
<div className="text-center">
|
<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>
|
<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">Loading image...</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading image...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified text and code file editor */}
|
|
||||||
{shouldShowAsText && !showLargeFileWarning && (
|
{shouldShowAsText && !showLargeFileWarning && (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Unified CodeMirror editor for all text-based files
|
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={editedContent}
|
value={editedContent}
|
||||||
@@ -756,20 +763,18 @@ export function FileViewer({
|
|||||||
...searchKeymap,
|
...searchKeymap,
|
||||||
...historyKeymap,
|
...historyKeymap,
|
||||||
...completionKeymap,
|
...completionKeymap,
|
||||||
// Custom keybindings
|
|
||||||
{
|
{
|
||||||
key: "Mod-/",
|
key: "Mod-/",
|
||||||
run: toggleComment,
|
run: toggleComment,
|
||||||
preventDefault: true
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Mod-h",
|
key: "Mod-h",
|
||||||
run: () => {
|
run: () => {
|
||||||
// Let CodeMirror search handle this, just prevent browser default
|
return false;
|
||||||
return false; // Return false to let search keymap handle it
|
},
|
||||||
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
preventDefault: true
|
|
||||||
}
|
|
||||||
]),
|
]),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
@@ -800,7 +805,6 @@ export function FileViewer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Read-only view for non-editable files
|
|
||||||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||||||
{editedContent || content || t("fileManager.fileIsEmpty")}
|
{editedContent || content || t("fileManager.fileIsEmpty")}
|
||||||
</div>
|
</div>
|
||||||
@@ -808,22 +812,29 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video file preview with enhanced HTML5 support */}
|
|
||||||
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
||||||
<div className="p-6 flex items-center justify-center h-full">
|
<div className="p-6 flex items-center justify-center h-full">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
{(() => {
|
{(() => {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||||
const mimeType = (() => {
|
const mimeType = (() => {
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'mp4': return 'video/mp4';
|
case "mp4":
|
||||||
case 'webm': return 'video/webm';
|
return "video/mp4";
|
||||||
case 'mkv': return 'video/x-matroska';
|
case "webm":
|
||||||
case 'avi': return 'video/x-msvideo';
|
return "video/webm";
|
||||||
case 'mov': return 'video/quicktime';
|
case "mkv":
|
||||||
case 'wmv': return 'video/x-ms-wmv';
|
return "video/x-matroska";
|
||||||
case 'flv': return 'video/x-flv';
|
case "avi":
|
||||||
default: return 'video/mp4';
|
return "video/x-msvideo";
|
||||||
|
case "mov":
|
||||||
|
return "video/quicktime";
|
||||||
|
case "wmv":
|
||||||
|
return "video/x-ms-wmv";
|
||||||
|
case "flv":
|
||||||
|
return "video/x-flv";
|
||||||
|
default:
|
||||||
|
return "video/mp4";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -836,35 +847,36 @@ export function FileViewer({
|
|||||||
className="w-full rounded-lg shadow-sm"
|
className="w-full rounded-lg shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "calc(100vh - 200px)",
|
maxHeight: "calc(100vh - 200px)",
|
||||||
backgroundColor: "#000"
|
backgroundColor: "#000",
|
||||||
}}
|
}}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Video playback error:', e.currentTarget.error);
|
console.error(
|
||||||
}}
|
"Video playback error:",
|
||||||
onLoadStart={() => {
|
e.currentTarget.error,
|
||||||
console.log('Video loading started...');
|
);
|
||||||
}}
|
}}
|
||||||
onLoadedMetadata={(e) => {
|
onLoadedMetadata={(e) => {
|
||||||
const video = e.currentTarget;
|
const video = e.currentTarget;
|
||||||
console.log('Video metadata loaded, dimensions:', video.videoWidth, 'x', video.videoHeight);
|
if (
|
||||||
|
onMediaDimensionsChange &&
|
||||||
// Get video dimensions and notify parent
|
video.videoWidth &&
|
||||||
if (onMediaDimensionsChange && video.videoWidth && video.videoHeight) {
|
video.videoHeight
|
||||||
|
) {
|
||||||
onMediaDimensionsChange({
|
onMediaDimensionsChange({
|
||||||
width: video.videoWidth,
|
width: video.videoWidth,
|
||||||
height: video.videoHeight
|
height: video.videoHeight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCanPlay={() => {
|
|
||||||
console.log('Video can start playing');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<source src={videoUrl} type={mimeType} />
|
<source src={videoUrl} type={mimeType} />
|
||||||
<div className="text-center text-muted-foreground p-4">
|
<div className="text-center text-muted-foreground p-4">
|
||||||
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
||||||
<p>Your browser does not support video playback for this format.</p>
|
<p>
|
||||||
|
Your browser does not support video playback for this
|
||||||
|
format.
|
||||||
|
</p>
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -884,10 +896,8 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Markdown file editor with live preview */}
|
|
||||||
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
|
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Markdown toolbar */}
|
|
||||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
|
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -908,17 +918,13 @@ export function FileViewer({
|
|||||||
{t("fileManager.preview")}
|
{t("fileManager.preview")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2"></div>
|
||||||
{/* Save button removed - using the main header save button instead */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown content area */}
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{markdownEditMode ? (
|
{markdownEditMode ? (
|
||||||
<>
|
<>
|
||||||
{/* Editor pane */}
|
|
||||||
<div className="flex-1 border-r border-border">
|
<div className="flex-1 border-r border-border">
|
||||||
<div className="h-full p-4 bg-background">
|
<div className="h-full p-4 bg-background">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -933,14 +939,21 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview pane */}
|
|
||||||
<div className="flex-1 overflow-auto bg-muted/10">
|
<div className="flex-1 overflow-auto bg-muted/10">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
code({ node, inline, className, children, ...props }) {
|
code({
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
node,
|
||||||
|
inline,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const match = /language-(\w+)/.exec(
|
||||||
|
className || "",
|
||||||
|
);
|
||||||
return !inline && match ? (
|
return !inline && match ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
@@ -949,10 +962,13 @@ export function FileViewer({
|
|||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{String(children).replace(/\n$/, '')}
|
{String(children).replace(/\n$/, "")}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : (
|
) : (
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
|
<code
|
||||||
|
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -993,9 +1009,7 @@ export function FileViewer({
|
|||||||
</ol>
|
</ol>
|
||||||
),
|
),
|
||||||
li: ({ children }) => (
|
li: ({ children }) => (
|
||||||
<li className="mb-1 text-foreground">
|
<li className="mb-1 text-foreground">{children}</li>
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
),
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
|
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
|
||||||
@@ -1010,15 +1024,9 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children }) => (
|
thead: ({ children }) => (
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted">{children}</thead>
|
||||||
{children}
|
|
||||||
</thead>
|
|
||||||
),
|
|
||||||
tbody: ({ children }) => (
|
|
||||||
<tbody>
|
|
||||||
{children}
|
|
||||||
</tbody>
|
|
||||||
),
|
),
|
||||||
|
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||||
tr: ({ children }) => (
|
tr: ({ children }) => (
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
{children}
|
{children}
|
||||||
@@ -1059,7 +1067,7 @@ export function FileViewer({
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
code({ node, inline, className, children, ...props }) {
|
code({ node, inline, className, children, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
return !inline && match ? (
|
return !inline && match ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
@@ -1068,10 +1076,13 @@ export function FileViewer({
|
|||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{String(children).replace(/\n$/, '')}
|
{String(children).replace(/\n$/, "")}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : (
|
) : (
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
|
<code
|
||||||
|
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -1112,9 +1123,7 @@ export function FileViewer({
|
|||||||
</ol>
|
</ol>
|
||||||
),
|
),
|
||||||
li: ({ children }) => (
|
li: ({ children }) => (
|
||||||
<li className="mb-1 text-foreground">
|
<li className="mb-1 text-foreground">{children}</li>
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
),
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
|
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
|
||||||
@@ -1129,19 +1138,11 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children }) => (
|
thead: ({ children }) => (
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted">{children}</thead>
|
||||||
{children}
|
|
||||||
</thead>
|
|
||||||
),
|
|
||||||
tbody: ({ children }) => (
|
|
||||||
<tbody>
|
|
||||||
{children}
|
|
||||||
</tbody>
|
|
||||||
),
|
),
|
||||||
|
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||||
tr: ({ children }) => (
|
tr: ({ children }) => (
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">{children}</tr>
|
||||||
{children}
|
|
||||||
</tr>
|
|
||||||
),
|
),
|
||||||
th: ({ children }) => (
|
th: ({ children }) => (
|
||||||
<th className="px-4 py-2 text-left font-semibold text-foreground">
|
<th className="px-4 py-2 text-left font-semibold text-foreground">
|
||||||
@@ -1174,10 +1175,8 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PDF file preview with react-pdf */}
|
|
||||||
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
||||||
<div className="h-full flex flex-col bg-background">
|
<div className="h-full flex flex-col bg-background">
|
||||||
{/* PDF Controls */}
|
|
||||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -1191,12 +1190,17 @@ export function FileViewer({
|
|||||||
{t("fileManager.previous")}
|
{t("fileManager.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||||||
{t("fileManager.pageXOfY", { current: pageNumber, total: numPages || 0 })}
|
{t("fileManager.pageXOfY", {
|
||||||
|
current: pageNumber,
|
||||||
|
total: numPages || 0,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPageNumber(Math.min(numPages || 1, pageNumber + 1))}
|
onClick={() =>
|
||||||
|
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||||||
|
}
|
||||||
disabled={!numPages || pageNumber >= numPages}
|
disabled={!numPages || pageNumber >= numPages}
|
||||||
>
|
>
|
||||||
{t("fileManager.next")}
|
{t("fileManager.next")}
|
||||||
@@ -1236,13 +1240,14 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF Content */}
|
|
||||||
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{pdfError ? (
|
{pdfError ? (
|
||||||
<div className="text-center text-muted-foreground p-8">
|
<div className="text-center text-muted-foreground p-8">
|
||||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
|
<h3 className="text-lg font-medium mb-2">
|
||||||
|
Cannot load PDF
|
||||||
|
</h3>
|
||||||
<p className="text-sm mb-4">
|
<p className="text-sm mb-4">
|
||||||
There was an error loading this PDF file.
|
There was an error loading this PDF file.
|
||||||
</p>
|
</p>
|
||||||
@@ -1264,22 +1269,23 @@ export function FileViewer({
|
|||||||
setNumPages(numPages);
|
setNumPages(numPages);
|
||||||
setPdfError(false);
|
setPdfError(false);
|
||||||
|
|
||||||
// Notify parent about PDF dimensions for window sizing
|
|
||||||
if (onMediaDimensionsChange) {
|
if (onMediaDimensionsChange) {
|
||||||
onMediaDimensionsChange({
|
onMediaDimensionsChange({
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600
|
height: 600,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoadError={(error) => {
|
onLoadError={(error) => {
|
||||||
console.error('PDF load error:', error);
|
console.error("PDF load error:", error);
|
||||||
setPdfError(true);
|
setPdfError(true);
|
||||||
}}
|
}}
|
||||||
loading={
|
loading={
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
<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">Loading PDF...</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading PDF...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1290,7 +1296,9 @@ export function FileViewer({
|
|||||||
loading={
|
loading={
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||||
<p className="text-xs text-muted-foreground">Loading page...</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Loading page...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1301,21 +1309,27 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audio file preview with react-h5-audio-player */}
|
|
||||||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||||||
<div className="p-6 flex items-center justify-center h-full">
|
<div className="p-6 flex items-center justify-center h-full">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
{(() => {
|
{(() => {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||||
const mimeType = (() => {
|
const mimeType = (() => {
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'mp3': return 'audio/mpeg';
|
case "mp3":
|
||||||
case 'wav': return 'audio/wav';
|
return "audio/mpeg";
|
||||||
case 'flac': return 'audio/flac';
|
case "wav":
|
||||||
case 'ogg': return 'audio/ogg';
|
return "audio/wav";
|
||||||
case 'aac': return 'audio/aac';
|
case "flac":
|
||||||
case 'm4a': return 'audio/mp4';
|
return "audio/flac";
|
||||||
default: return 'audio/mpeg';
|
case "ogg":
|
||||||
|
return "audio/ogg";
|
||||||
|
case "aac":
|
||||||
|
return "audio/aac";
|
||||||
|
case "m4a":
|
||||||
|
return "audio/mp4";
|
||||||
|
default:
|
||||||
|
return "audio/mpeg";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -1323,7 +1337,6 @@ export function FileViewer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Album artwork placeholder */}
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1335,7 +1348,6 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||||||
{file.name.replace(/\.[^/.]+$/, "")}
|
{file.name.replace(/\.[^/.]+$/, "")}
|
||||||
@@ -1345,30 +1357,20 @@ export function FileViewer({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio Player */}
|
|
||||||
<div className="rounded-lg overflow-hidden">
|
<div className="rounded-lg overflow-hidden">
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={audioUrl}
|
src={audioUrl}
|
||||||
onPlay={() => {
|
|
||||||
console.log('Audio playback started');
|
|
||||||
}}
|
|
||||||
onPause={() => {
|
|
||||||
console.log('Audio playback paused');
|
|
||||||
}}
|
|
||||||
onLoadedMetadata={(e) => {
|
onLoadedMetadata={(e) => {
|
||||||
const audio = e.currentTarget;
|
const audio = e.currentTarget;
|
||||||
console.log('Audio metadata loaded, duration:', audio.duration);
|
|
||||||
|
|
||||||
// Get audio dimensions for window sizing (use a standard audio player height)
|
|
||||||
if (onMediaDimensionsChange) {
|
if (onMediaDimensionsChange) {
|
||||||
onMediaDimensionsChange({
|
onMediaDimensionsChange({
|
||||||
width: 600,
|
width: 600,
|
||||||
height: 400
|
height: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Audio playback error:', e);
|
console.error("Audio playback error:", e);
|
||||||
}}
|
}}
|
||||||
showJumpControls={false}
|
showJumpControls={false}
|
||||||
showSkipControls={false}
|
showSkipControls={false}
|
||||||
@@ -1384,7 +1386,6 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unknown file type - only show when cannot display as text and no warning */}
|
|
||||||
{fileTypeInfo.type === "unknown" &&
|
{fileTypeInfo.type === "unknown" &&
|
||||||
!shouldShowAsText &&
|
!shouldShowAsText &&
|
||||||
!showLargeFileWarning && (
|
!showLargeFileWarning && (
|
||||||
@@ -1413,7 +1414,6 @@ export function FileViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom status bar */}
|
|
||||||
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>{file.path}</span>
|
<span>{file.path}</span>
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ interface FileWindowProps {
|
|||||||
sshHost: SSHHost;
|
sshHost: SSHHost;
|
||||||
initialX?: number;
|
initialX?: number;
|
||||||
initialY?: number;
|
initialY?: number;
|
||||||
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
|
onFileNotFound?: (file: FileItem) => void;
|
||||||
// readOnly parameter removed, determined internally by FileViewer based on file type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileWindow({
|
export function FileWindow({
|
||||||
@@ -57,13 +56,8 @@ export function FileWindow({
|
|||||||
initialY = 100,
|
initialY = 100,
|
||||||
onFileNotFound,
|
onFileNotFound,
|
||||||
}: FileWindowProps) {
|
}: FileWindowProps) {
|
||||||
const {
|
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
|
||||||
closeWindow,
|
useWindowManager();
|
||||||
maximizeWindow,
|
|
||||||
focusWindow,
|
|
||||||
updateWindow,
|
|
||||||
windows,
|
|
||||||
} = useWindowManager();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -71,22 +65,18 @@ export function FileWindow({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isEditable, setIsEditable] = useState(false);
|
const [isEditable, setIsEditable] = useState(false);
|
||||||
const [pendingContent, setPendingContent] = useState<string>("");
|
const [pendingContent, setPendingContent] = useState<string>("");
|
||||||
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
|
const [mediaDimensions, setMediaDimensions] = useState<
|
||||||
|
{ width: number; height: number } | undefined
|
||||||
|
>();
|
||||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const currentWindow = windows.find((w) => w.id === windowId);
|
const currentWindow = windows.find((w) => w.id === windowId);
|
||||||
|
|
||||||
// Ensure SSH connection is valid
|
|
||||||
const ensureSSHConnection = async () => {
|
const ensureSSHConnection = async () => {
|
||||||
try {
|
try {
|
||||||
// First check SSH connection status
|
|
||||||
const status = await getSSHStatus(sshSessionId);
|
const status = await getSSHStatus(sshSessionId);
|
||||||
console.log("SSH connection status:", status);
|
|
||||||
|
|
||||||
if (!status.connected) {
|
if (!status.connected) {
|
||||||
console.log("SSH not connected, attempting to reconnect...");
|
|
||||||
|
|
||||||
// Re-establish connection
|
|
||||||
await connectSSH(sshSessionId, {
|
await connectSSH(sshSessionId, {
|
||||||
hostId: sshHost.id,
|
hostId: sshHost.id,
|
||||||
ip: sshHost.ip,
|
ip: sshHost.ip,
|
||||||
@@ -99,17 +89,13 @@ export function FileWindow({
|
|||||||
credentialId: sshHost.credentialId,
|
credentialId: sshHost.credentialId,
|
||||||
userId: sshHost.userId,
|
userId: sshHost.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("SSH reconnection successful");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("SSH connection check/reconnect failed:", error);
|
console.error("SSH connection check/reconnect failed:", error);
|
||||||
// Even if connection fails, try to continue and let specific API calls handle errors
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load file content
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFileContent = async () => {
|
const loadFileContent = async () => {
|
||||||
if (file.type !== "file") return;
|
if (file.type !== "file") return;
|
||||||
@@ -117,23 +103,19 @@ export function FileWindow({
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Ensure SSH connection is valid
|
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
const response = await readSSHFile(sshSessionId, file.path);
|
const response = await readSSHFile(sshSessionId, file.path);
|
||||||
const fileContent = response.content || "";
|
const fileContent = response.content || "";
|
||||||
setContent(fileContent);
|
setContent(fileContent);
|
||||||
setPendingContent(fileContent); // Initialize pending content
|
setPendingContent(fileContent);
|
||||||
|
|
||||||
// If file size is unknown, calculate size based on content
|
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
const contentSize = new Blob([fileContent]).size;
|
const contentSize = new Blob([fileContent]).size;
|
||||||
file.size = contentSize;
|
file.size = contentSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if editable based on file type: all except media files are editable
|
|
||||||
const mediaExtensions = [
|
const mediaExtensions = [
|
||||||
// Image files
|
|
||||||
"jpg",
|
"jpg",
|
||||||
"jpeg",
|
"jpeg",
|
||||||
"png",
|
"png",
|
||||||
@@ -143,7 +125,6 @@ export function FileWindow({
|
|||||||
"webp",
|
"webp",
|
||||||
"tiff",
|
"tiff",
|
||||||
"ico",
|
"ico",
|
||||||
// Audio files
|
|
||||||
"mp3",
|
"mp3",
|
||||||
"wav",
|
"wav",
|
||||||
"ogg",
|
"ogg",
|
||||||
@@ -151,7 +132,6 @@ export function FileWindow({
|
|||||||
"flac",
|
"flac",
|
||||||
"m4a",
|
"m4a",
|
||||||
"wma",
|
"wma",
|
||||||
// Video files
|
|
||||||
"mp4",
|
"mp4",
|
||||||
"avi",
|
"avi",
|
||||||
"mov",
|
"mov",
|
||||||
@@ -160,7 +140,6 @@ export function FileWindow({
|
|||||||
"mkv",
|
"mkv",
|
||||||
"webm",
|
"webm",
|
||||||
"m4v",
|
"m4v",
|
||||||
// Archive files
|
|
||||||
"zip",
|
"zip",
|
||||||
"rar",
|
"rar",
|
||||||
"7z",
|
"7z",
|
||||||
@@ -168,7 +147,6 @@ export function FileWindow({
|
|||||||
"gz",
|
"gz",
|
||||||
"bz2",
|
"bz2",
|
||||||
"xz",
|
"xz",
|
||||||
// Binary files
|
|
||||||
"exe",
|
"exe",
|
||||||
"dll",
|
"dll",
|
||||||
"so",
|
"so",
|
||||||
@@ -178,28 +156,25 @@ export function FileWindow({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
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 || ""));
|
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to load file:", error);
|
console.error("Failed to load file:", error);
|
||||||
|
|
||||||
// Check if it's a large file error
|
|
||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.tooLarge) {
|
if (errorData?.tooLarge) {
|
||||||
toast.error(`File too large: ${errorData.error}`, {
|
toast.error(`File too large: ${errorData.error}`, {
|
||||||
duration: 10000, // 10 seconds for important message
|
duration: 10000,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
error.message?.includes("connection") ||
|
error.message?.includes("connection") ||
|
||||||
error.message?.includes("established")
|
error.message?.includes("established")
|
||||||
) {
|
) {
|
||||||
// If connection error, provide more specific error message
|
|
||||||
toast.error(
|
toast.error(
|
||||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Check if file not found (common error messages from cat command)
|
const errorMessage =
|
||||||
const errorMessage = errorData?.error || error.message || "Unknown error";
|
errorData?.error || error.message || "Unknown error";
|
||||||
const isFileNotFound =
|
const isFileNotFound =
|
||||||
(error as any).isFileNotFound ||
|
(error as any).isFileNotFound ||
|
||||||
errorData?.fileNotFound ||
|
errorData?.fileNotFound ||
|
||||||
@@ -211,19 +186,21 @@ export function FileWindow({
|
|||||||
errorMessage.includes("Resource not found");
|
errorMessage.includes("Resource not found");
|
||||||
|
|
||||||
if (isFileNotFound && onFileNotFound) {
|
if (isFileNotFound && onFileNotFound) {
|
||||||
// Notify parent component about the missing file for cleanup
|
|
||||||
onFileNotFound(file);
|
onFileNotFound(file);
|
||||||
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
|
toast.error(
|
||||||
|
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
|
||||||
|
);
|
||||||
|
|
||||||
// Close this window since the file doesn't exist
|
|
||||||
closeWindow(windowId);
|
closeWindow(windowId);
|
||||||
return; // Exit early to prevent showing empty editor
|
return;
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fileManager.failedToLoadFile", {
|
toast.error(
|
||||||
error: errorMessage.includes("Server error occurred") ?
|
t("fileManager.failedToLoadFile", {
|
||||||
t("fileManager.serverErrorOccurred") :
|
error: errorMessage.includes("Server error occurred")
|
||||||
errorMessage
|
? t("fileManager.serverErrorOccurred")
|
||||||
}));
|
: errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,19 +211,16 @@ export function FileWindow({
|
|||||||
loadFileContent();
|
loadFileContent();
|
||||||
}, [file, sshSessionId, sshHost]);
|
}, [file, sshSessionId, sshHost]);
|
||||||
|
|
||||||
// Save file
|
|
||||||
const handleSave = async (newContent: string) => {
|
const handleSave = async (newContent: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Ensure SSH connection is valid
|
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||||
setContent(newContent);
|
setContent(newContent);
|
||||||
setPendingContent(""); // Clear pending content
|
setPendingContent("");
|
||||||
|
|
||||||
// Clear auto-save timer
|
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
clearTimeout(autoSaveTimerRef.current);
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
autoSaveTimerRef.current = null;
|
autoSaveTimerRef.current = null;
|
||||||
@@ -256,7 +230,6 @@ export function FileWindow({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to save file:", error);
|
console.error("Failed to save file:", error);
|
||||||
|
|
||||||
// If it's a connection error, provide more specific error message
|
|
||||||
if (
|
if (
|
||||||
error.message?.includes("connection") ||
|
error.message?.includes("connection") ||
|
||||||
error.message?.includes("established")
|
error.message?.includes("established")
|
||||||
@@ -265,36 +238,33 @@ export function FileWindow({
|
|||||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
|
toast.error(
|
||||||
|
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle content changes - set 1-minute auto-save
|
|
||||||
const handleContentChange = (newContent: string) => {
|
const handleContentChange = (newContent: string) => {
|
||||||
setPendingContent(newContent);
|
setPendingContent(newContent);
|
||||||
|
|
||||||
// Clear previous timer
|
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
clearTimeout(autoSaveTimerRef.current);
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set new 1-minute auto-save timer
|
|
||||||
autoSaveTimerRef.current = setTimeout(async () => {
|
autoSaveTimerRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Auto-saving file...");
|
|
||||||
await handleSave(newContent);
|
await handleSave(newContent);
|
||||||
toast.success(t("fileManager.fileAutoSaved"));
|
toast.success(t("fileManager.fileAutoSaved"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto-save failed:", error);
|
console.error("Auto-save failed:", error);
|
||||||
toast.error(t("fileManager.autoSaveFailed"));
|
toast.error(t("fileManager.autoSaveFailed"));
|
||||||
}
|
}
|
||||||
}, 60000); // 1 minute = 60000 milliseconds
|
}, 60000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup timer
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
@@ -303,16 +273,13 @@ export function FileWindow({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Download file
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
// Ensure SSH connection is valid
|
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
|
|
||||||
if (response?.content) {
|
if (response?.content) {
|
||||||
// Convert base64 to blob and trigger download
|
|
||||||
const byteCharacters = atob(response.content);
|
const byteCharacters = atob(response.content);
|
||||||
const byteNumbers = new Array(byteCharacters.length);
|
const byteNumbers = new Array(byteCharacters.length);
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
@@ -337,7 +304,6 @@ export function FileWindow({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to download file:", error);
|
console.error("Failed to download file:", error);
|
||||||
|
|
||||||
// If it's a connection error, provide more specific error message
|
|
||||||
if (
|
if (
|
||||||
error.message?.includes("connection") ||
|
error.message?.includes("connection") ||
|
||||||
error.message?.includes("established")
|
error.message?.includes("established")
|
||||||
@@ -353,7 +319,6 @@ export function FileWindow({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Window operation handling
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
closeWindow(windowId);
|
closeWindow(windowId);
|
||||||
};
|
};
|
||||||
@@ -366,9 +331,10 @@ export function FileWindow({
|
|||||||
focusWindow(windowId);
|
focusWindow(windowId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle media dimensions change
|
const handleMediaDimensionsChange = (dimensions: {
|
||||||
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
|
width: number;
|
||||||
console.log('Media dimensions received:', dimensions);
|
height: number;
|
||||||
|
}) => {
|
||||||
setMediaDimensions(dimensions);
|
setMediaDimensions(dimensions);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -397,7 +363,7 @@ export function FileWindow({
|
|||||||
content={pendingContent || content}
|
content={pendingContent || content}
|
||||||
savedContent={content}
|
savedContent={content}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
|
isEditable={isEditable}
|
||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
onSave={(newContent) => handleSave(newContent)}
|
onSave={(newContent) => handleSave(newContent)}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ export function TerminalWindow({
|
|||||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||||
useWindowManager();
|
useWindowManager();
|
||||||
|
|
||||||
// Get current window state
|
|
||||||
const currentWindow = windows.find((w) => w.id === windowId);
|
const currentWindow = windows.find((w) => w.id === windowId);
|
||||||
if (!currentWindow) {
|
if (!currentWindow) {
|
||||||
console.warn(`Window with id ${windowId} not found`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +63,10 @@ export function TerminalWindow({
|
|||||||
const terminalTitle = executeCommand
|
const terminalTitle = executeCommand
|
||||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||||
: initialPath
|
: initialPath
|
||||||
? t("terminal.terminalWithPath", { host: hostConfig.name, path: initialPath })
|
? t("terminal.terminalWithPath", {
|
||||||
|
host: hostConfig.name,
|
||||||
|
path: initialPath,
|
||||||
|
})
|
||||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,13 +35,11 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
const nextZIndex = useRef(1000);
|
const nextZIndex = useRef(1000);
|
||||||
const windowCounter = useRef(0);
|
const windowCounter = useRef(0);
|
||||||
|
|
||||||
// Open new window
|
|
||||||
const openWindow = useCallback(
|
const openWindow = useCallback(
|
||||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||||
const id = `window-${++windowCounter.current}`;
|
const id = `window-${++windowCounter.current}`;
|
||||||
const zIndex = ++nextZIndex.current;
|
const zIndex = ++nextZIndex.current;
|
||||||
|
|
||||||
// Calculate offset position to avoid windows completely overlapping
|
|
||||||
const offset = (windows.length % 5) * 30;
|
const offset = (windows.length % 5) * 30;
|
||||||
const adjustedX = windowData.x + offset;
|
const adjustedX = windowData.x + offset;
|
||||||
const adjustedY = windowData.y + offset;
|
const adjustedY = windowData.y + offset;
|
||||||
@@ -60,12 +58,10 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
[windows.length],
|
[windows.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close window
|
|
||||||
const closeWindow = useCallback((id: string) => {
|
const closeWindow = useCallback((id: string) => {
|
||||||
setWindows((prev) => prev.filter((w) => w.id !== id));
|
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Minimize window
|
|
||||||
const minimizeWindow = useCallback((id: string) => {
|
const minimizeWindow = useCallback((id: string) => {
|
||||||
setWindows((prev) =>
|
setWindows((prev) =>
|
||||||
prev.map((w) =>
|
prev.map((w) =>
|
||||||
@@ -74,7 +70,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Maximize/restore window
|
|
||||||
const maximizeWindow = useCallback((id: string) => {
|
const maximizeWindow = useCallback((id: string) => {
|
||||||
setWindows((prev) =>
|
setWindows((prev) =>
|
||||||
prev.map((w) =>
|
prev.map((w) =>
|
||||||
@@ -83,7 +78,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus window (bring to top)
|
|
||||||
const focusWindow = useCallback((id: string) => {
|
const focusWindow = useCallback((id: string) => {
|
||||||
setWindows((prev) => {
|
setWindows((prev) => {
|
||||||
const targetWindow = prev.find((w) => w.id === id);
|
const targetWindow = prev.find((w) => w.id === id);
|
||||||
@@ -94,7 +88,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update window properties
|
|
||||||
const updateWindow = useCallback(
|
const updateWindow = useCallback(
|
||||||
(id: string, updates: Partial<WindowInstance>) => {
|
(id: string, updates: Partial<WindowInstance>) => {
|
||||||
setWindows((prev) =>
|
setWindows((prev) =>
|
||||||
@@ -117,7 +110,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<WindowManagerContext.Provider value={contextValue}>
|
<WindowManagerContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{/* Render all windows */}
|
|
||||||
<div className="window-container">
|
<div className="window-container">
|
||||||
{windows.map((window) => (
|
{windows.map((window) => (
|
||||||
<div key={window.id}>
|
<div key={window.id}>
|
||||||
@@ -131,7 +123,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook for using window manager
|
|
||||||
export function useWindowManager() {
|
export function useWindowManager() {
|
||||||
const context = React.useContext(WindowManagerContext);
|
const context = React.useContext(WindowManagerContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -450,30 +450,31 @@ export function HostManagerEditor({
|
|||||||
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle AutoStart plaintext cache management
|
|
||||||
if (savedHost && savedHost.id && data.tunnelConnections) {
|
if (savedHost && savedHost.id && data.tunnelConnections) {
|
||||||
const hasAutoStartTunnels = data.tunnelConnections.some(tunnel => tunnel.autoStart);
|
const hasAutoStartTunnels = data.tunnelConnections.some(
|
||||||
|
(tunnel) => tunnel.autoStart,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasAutoStartTunnels) {
|
if (hasAutoStartTunnels) {
|
||||||
// User has enabled autoStart on some tunnels
|
|
||||||
// Need to ensure plaintext cache exists for this host
|
|
||||||
try {
|
try {
|
||||||
await enableAutoStart(savedHost.id);
|
await enableAutoStart(savedHost.id);
|
||||||
console.log(`AutoStart plaintext cache enabled for SSH host ${savedHost.id}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
console.warn(
|
||||||
// Don't fail the whole operation if cache setup fails
|
`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
||||||
toast.warning(t("hosts.autoStartEnableFailed", { name: data.name }));
|
error,
|
||||||
|
);
|
||||||
|
toast.warning(
|
||||||
|
t("hosts.autoStartEnableFailed", { name: data.name }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// User has disabled autoStart on all tunnels
|
|
||||||
// Clean up plaintext cache for this host
|
|
||||||
try {
|
try {
|
||||||
await disableAutoStart(savedHost.id);
|
await disableAutoStart(savedHost.id);
|
||||||
console.log(`AutoStart plaintext cache disabled for SSH host ${savedHost.id}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
console.warn(
|
||||||
// Don't fail the whole operation
|
`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -990,7 +991,9 @@ export function HostManagerEditor({
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onChange={(value) => field.onChange(value)}
|
onChange={(value) => field.onChange(value)}
|
||||||
placeholder={t("placeholders.pastePrivateKey")}
|
placeholder={t(
|
||||||
|
"placeholders.pastePrivateKey",
|
||||||
|
)}
|
||||||
theme={oneDark}
|
theme={oneDark}
|
||||||
className="border border-input rounded-md"
|
className="border border-input rounded-md"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ export function Server({
|
|||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{/* Top Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -271,7 +270,6 @@ export function Server({
|
|||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full" />
|
<Separator className="p-0.25 w-full" />
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{showStatsUI && (
|
{showStatsUI && (
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||||
{isLoadingMetrics && !metrics ? (
|
{isLoadingMetrics && !metrics ? (
|
||||||
|
|||||||
@@ -36,18 +36,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
// DEBUG: Add global JWT test function (only once)
|
if (typeof window !== "undefined" && !(window as any).testJWT) {
|
||||||
if (typeof window !== 'undefined' && !(window as any).testJWT) {
|
|
||||||
(window as any).testJWT = () => {
|
(window as any).testJWT = () => {
|
||||||
const jwt = getCookie("jwt");
|
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;
|
return jwt;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,35 +74,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
isVisibleRef.current = isVisible;
|
isVisibleRef.current = isVisible;
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Monitor authentication state - Linus principle: explicit state management
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||||
|
|
||||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
setIsAuthenticated((prev) => {
|
||||||
setIsAuthenticated(prev => {
|
|
||||||
if (prev !== isAuth) {
|
if (prev !== isAuth) {
|
||||||
console.debug("Auth State Changed:", {
|
|
||||||
from: prev,
|
|
||||||
to: isAuth,
|
|
||||||
jwtPresent: !!jwtToken,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
return isAuth;
|
return isAuth;
|
||||||
}
|
}
|
||||||
return prev; // No change, don't trigger re-render
|
return prev;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check immediately
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
// Reduced frequency - check every 5 seconds instead of every second
|
|
||||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||||
|
|
||||||
return () => clearInterval(authCheckInterval);
|
return () => clearInterval(authCheckInterval);
|
||||||
}, []); // No dependencies - prevent infinite loop
|
}, []);
|
||||||
|
|
||||||
function hardRefresh() {
|
function hardRefresh() {
|
||||||
try {
|
try {
|
||||||
@@ -187,8 +168,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
[terminal],
|
[terminal],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resize handling moved to AppView to avoid conflicts - Linus principle: eliminate duplicate complexity
|
|
||||||
|
|
||||||
function handleWindowResize() {
|
function handleWindowResize() {
|
||||||
if (!isVisibleRef.current) return;
|
if (!isVisibleRef.current) return;
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
@@ -207,7 +186,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
isReconnectingRef.current ||
|
isReconnectingRef.current ||
|
||||||
isConnectingRef.current
|
isConnectingRef.current
|
||||||
) {
|
) {
|
||||||
console.debug("Skipping reconnection - already in progress or blocked");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +223,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify authentication before attempting reconnection
|
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
if (!jwtToken || jwtToken.trim() === "") {
|
if (!jwtToken || jwtToken.trim() === "") {
|
||||||
console.warn("Reconnection cancelled - no authentication token");
|
console.warn("Reconnection cancelled - no authentication token");
|
||||||
@@ -266,9 +243,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectToHost(cols: number, rows: number) {
|
function connectToHost(cols: number, rows: number) {
|
||||||
// Prevent duplicate connections - Linus principle: fail fast
|
|
||||||
if (isConnectingRef.current) {
|
if (isConnectingRef.current) {
|
||||||
console.debug("Skipping connection - already connecting");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,26 +255,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
window.location.port === "5173" ||
|
window.location.port === "5173" ||
|
||||||
window.location.port === "");
|
window.location.port === "");
|
||||||
|
|
||||||
// Get JWT token for WebSocket authentication (from cookie, not localStorage)
|
|
||||||
const jwtToken = getCookie("jwt");
|
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() === "") {
|
if (!jwtToken || jwtToken.trim() === "") {
|
||||||
console.error("No JWT token available for WebSocket connection");
|
console.error("No JWT token available for WebSocket connection");
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
setConnectionError("Authentication required");
|
setConnectionError("Authentication required");
|
||||||
isConnectingRef.current = false; // Reset on auth failure
|
isConnectingRef.current = false;
|
||||||
// Don't show toast here - let auth system handle it
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,13 +280,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
if (
|
||||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
webSocketRef.current &&
|
||||||
console.log("Closing existing WebSocket connection before creating new one");
|
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||||
|
) {
|
||||||
webSocketRef.current.close();
|
webSocketRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing intervals/timeouts
|
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
@@ -333,18 +296,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add JWT token as query parameter for authentication
|
|
||||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
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);
|
const ws = new WebSocket(wsUrl);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
@@ -439,7 +392,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
} else if (msg.type === "connected") {
|
} else if (msg.type === "connected") {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
if (connectionTimeoutRef.current) {
|
if (connectionTimeoutRef.current) {
|
||||||
clearTimeout(connectionTimeoutRef.current);
|
clearTimeout(connectionTimeoutRef.current);
|
||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
@@ -467,25 +420,21 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
|
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle authentication errors (code 1008)
|
|
||||||
if (event.code === 1008) {
|
if (event.code === 1008) {
|
||||||
console.error("WebSocket authentication failed:", event.reason);
|
console.error("WebSocket authentication failed:", event.reason);
|
||||||
setConnectionError("Authentication failed - please re-login");
|
setConnectionError("Authentication failed - please re-login");
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
shouldNotReconnectRef.current = true;
|
shouldNotReconnectRef.current = true;
|
||||||
|
|
||||||
// Clear invalid JWT token
|
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
|
|
||||||
// Show authentication error message
|
|
||||||
toast.error("Authentication failed. Please log in again.");
|
toast.error("Authentication failed. Please log in again.");
|
||||||
|
|
||||||
// Don't attempt to reconnect on auth failure
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +450,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
|
|
||||||
ws.addEventListener("error", (event) => {
|
ws.addEventListener("error", (event) => {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
setConnectionError(t("terminal.websocketError"));
|
setConnectionError(t("terminal.websocketError"));
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
@@ -546,12 +495,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
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 = {
|
terminal.options = {
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "bar",
|
cursorStyle: "bar",
|
||||||
@@ -563,7 +506,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
@@ -604,32 +546,26 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
};
|
};
|
||||||
element?.addEventListener("contextmenu", handleContextMenu);
|
element?.addEventListener("contextmenu", handleContextMenu);
|
||||||
|
|
||||||
// Add macOS-specific keyboard event handling for special characters
|
|
||||||
const handleMacKeyboard = (e: KeyboardEvent) => {
|
const handleMacKeyboard = (e: KeyboardEvent) => {
|
||||||
// Detect macOS
|
|
||||||
const isMacOS =
|
const isMacOS =
|
||||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
|
||||||
if (!isMacOS) return;
|
if (!isMacOS) return;
|
||||||
|
|
||||||
// Handle Option key combinations for special characters
|
|
||||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||||
// Use both e.key and e.code to handle different keyboard layouts
|
|
||||||
const keyMappings: { [key: string]: string } = {
|
const keyMappings: { [key: string]: string } = {
|
||||||
// Using e.key values
|
"7": "|",
|
||||||
"7": "|", // Option+7 = pipe symbol
|
"2": "€",
|
||||||
"2": "€", // Option+2 = euro symbol
|
"8": "[",
|
||||||
"8": "[", // Option+8 = left bracket
|
"9": "]",
|
||||||
"9": "]", // Option+9 = right bracket
|
l: "@",
|
||||||
l: "@", // Option+L = at symbol
|
L: "@",
|
||||||
L: "@", // Option+L = at symbol (uppercase)
|
Digit7: "|",
|
||||||
// Using e.code values as fallback
|
Digit2: "€",
|
||||||
Digit7: "|", // Option+7 = pipe symbol
|
Digit8: "[",
|
||||||
Digit2: "€", // Option+2 = euro symbol
|
Digit9: "]",
|
||||||
Digit8: "[", // Option+8 = left bracket
|
KeyL: "@",
|
||||||
Digit9: "]", // Option+9 = right bracket
|
|
||||||
KeyL: "@", // Option+L = at symbol
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const char = keyMappings[e.key] || keyMappings[e.code];
|
const char = keyMappings[e.key] || keyMappings[e.code];
|
||||||
@@ -637,7 +573,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Send the character directly to the terminal
|
|
||||||
if (webSocketRef.current?.readyState === 1) {
|
if (webSocketRef.current?.readyState === 1) {
|
||||||
webSocketRef.current.send(
|
webSocketRef.current.send(
|
||||||
JSON.stringify({ type: "input", data: char }),
|
JSON.stringify({ type: "input", data: char }),
|
||||||
@@ -657,12 +592,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
}, 150); // Increased debounce for better stability
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
resizeObserver.observe(xtermRef.current);
|
||||||
|
|
||||||
// Show terminal immediately - better UX, no unnecessary delays
|
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
|
||||||
const readyFonts =
|
const readyFonts =
|
||||||
@@ -671,7 +605,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
readyFonts.then(() => {
|
readyFonts.then(() => {
|
||||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
@@ -681,23 +614,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
terminal.focus();
|
terminal.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify authentication before attempting WebSocket connection
|
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
|
|
||||||
// DEBUG: Log only authentication failures
|
|
||||||
if (!jwtToken || jwtToken.trim() === "") {
|
if (!jwtToken || jwtToken.trim() === "") {
|
||||||
console.debug("ReadyFonts Auth Check Failed:", {
|
console.warn(
|
||||||
isAuthenticated: isAuthenticated,
|
"WebSocket connection delayed - no authentication token",
|
||||||
jwtPresent: !!jwtToken
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jwtToken || jwtToken.trim() === "") {
|
|
||||||
console.warn("WebSocket connection delayed - no authentication token");
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
setConnectionError("Authentication required");
|
setConnectionError("Authentication required");
|
||||||
// Don't show toast here - let auth system handle it
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +630,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
|
|
||||||
connectToHost(cols, rows);
|
connectToHost(cols, rows);
|
||||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -728,7 +653,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
}
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && fitAddonRef.current) {
|
if (isVisible && fitAddonRef.current) {
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ function AppContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
// With HttpOnly cookies, we can't check for JWT presence from frontend
|
|
||||||
// Instead, we'll try to get user info and handle the response
|
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
@@ -36,9 +34,7 @@ function AppContent() {
|
|||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
|
|
||||||
// Check if user data is unlocked
|
|
||||||
if (!meRes.data_unlocked) {
|
if (!meRes.data_unlocked) {
|
||||||
// Data is locked - user needs to re-authenticate
|
|
||||||
console.warn("User data is locked - re-authentication required");
|
console.warn("User data is locked - re-authentication required");
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -50,7 +46,6 @@ function AppContent() {
|
|||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
|
|
||||||
// Check if this is a session expiration error
|
|
||||||
const errorCode = err?.response?.data?.code;
|
const errorCode = err?.response?.data?.code;
|
||||||
if (errorCode === "SESSION_EXPIRED") {
|
if (errorCode === "SESSION_EXPIRED") {
|
||||||
console.warn("Session expired - please log in again");
|
console.warn("Session expired - please log in again");
|
||||||
|
|||||||
@@ -228,7 +228,9 @@ export function ServerConfig({
|
|||||||
{versionInfo && !versionDismissed && (
|
{versionInfo && !versionDismissed && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">{t("versionCheck.checkUpdates")}</h3>
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("versionCheck.checkUpdates")}
|
||||||
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ export function Homepage({
|
|||||||
setDbError(null);
|
setDbError(null);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Homepage: Error fetching user info:", err);
|
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
|
|
||||||
// Check if this is a session expiration error
|
|
||||||
const errorCode = err?.response?.data?.code;
|
const errorCode = err?.response?.data?.code;
|
||||||
if (errorCode === "SESSION_EXPIRED") {
|
if (errorCode === "SESSION_EXPIRED") {
|
||||||
console.warn("Session expired - please log in again");
|
console.warn("Session expired - please log in again");
|
||||||
|
|||||||
@@ -198,10 +198,6 @@ export function HomepageAuth({
|
|||||||
throw new Error(t("errors.loginFailed"));
|
throw new Error(t("errors.loginFailed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
|
||||||
// No need to manually manage the token on frontend
|
|
||||||
console.log("Login successful - JWT set as secure HttpOnly cookie");
|
|
||||||
|
|
||||||
[meRes] = await Promise.all([getUserInfo()]);
|
[meRes] = await Promise.all([getUserInfo()]);
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -245,10 +241,6 @@ export function HomepageAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===== Legacy password reset functions (deprecated) =====
|
// ===== Legacy password reset functions (deprecated) =====
|
||||||
|
|
||||||
async function handleInitiatePasswordReset() {
|
async function handleInitiatePasswordReset() {
|
||||||
@@ -317,7 +309,9 @@ export function HomepageAuth({
|
|||||||
setTab("login");
|
setTab("login");
|
||||||
resetPasswordState();
|
resetPasswordState();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
toast.error(
|
||||||
|
err?.response?.data?.error || t("errors.failedCompleteReset"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -630,9 +624,7 @@ export function HomepageAuth({
|
|||||||
{isElectron() && currentServerUrl && (
|
{isElectron() && currentServerUrl && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm text-muted-foreground">
|
<Label className="text-sm text-muted-foreground">Server</Label>
|
||||||
Server
|
|
||||||
</Label>
|
|
||||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
{currentServerUrl}
|
{currentServerUrl}
|
||||||
</div>
|
</div>
|
||||||
@@ -711,9 +703,7 @@ export function HomepageAuth({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!internalLoggedIn &&
|
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||||
!authLoading &&
|
|
||||||
!totpRequired && (
|
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
<button
|
<button
|
||||||
@@ -809,9 +799,7 @@ export function HomepageAuth({
|
|||||||
disabled={oidcLoading}
|
disabled={oidcLoading}
|
||||||
onClick={handleOIDCLogin}
|
onClick={handleOIDCLogin}
|
||||||
>
|
>
|
||||||
{oidcLoading
|
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||||
? Spinner
|
|
||||||
: t("auth.loginWithExternal")}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -885,9 +873,7 @@ export function HomepageAuth({
|
|||||||
disabled={resetLoading || resetCode.length !== 6}
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
onClick={handleVerifyResetCode}
|
onClick={handleVerifyResetCode}
|
||||||
>
|
>
|
||||||
{resetLoading
|
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||||
? Spinner
|
|
||||||
: t("auth.verifyCodeButton")}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -905,7 +891,6 @@ export function HomepageAuth({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{resetStep === "newPassword" && !resetSuccess && (
|
{resetStep === "newPassword" && !resetSuccess && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
@@ -938,9 +923,7 @@ export function HomepageAuth({
|
|||||||
required
|
required
|
||||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
setConfirmPassword(e.target.value)
|
|
||||||
}
|
|
||||||
disabled={resetLoading}
|
disabled={resetLoading}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
|
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getCookie, setCookie, isElectron, logoutUser } from "@/ui/main-axios.ts";
|
import {
|
||||||
|
getCookie,
|
||||||
|
setCookie,
|
||||||
|
isElectron,
|
||||||
|
logoutUser,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -68,19 +73,15 @@ interface SidebarProps {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
// Call backend logout endpoint to clear HttpOnly cookie and data session
|
|
||||||
await logoutUser();
|
await logoutUser();
|
||||||
|
|
||||||
// Clear any local storage (for Electron)
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the page to reset the application state
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
// Even if logout fails, reload the page to reset state
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,11 @@ interface LeftSidebarProps {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
// Call backend logout endpoint to clear HttpOnly cookie and data session
|
|
||||||
await logoutUser();
|
await logoutUser();
|
||||||
|
|
||||||
// Reload the page to reset the application state
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
// Even if logout fails, reload the page to reset state
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,35 +48,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
isVisibleRef.current = isVisible;
|
isVisibleRef.current = isVisible;
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Monitor authentication state - Linus principle: explicit state management
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||||
|
|
||||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
setIsAuthenticated((prev) => {
|
||||||
setIsAuthenticated(prev => {
|
|
||||||
if (prev !== isAuth) {
|
if (prev !== isAuth) {
|
||||||
console.debug("Mobile Auth State Changed:", {
|
|
||||||
from: prev,
|
|
||||||
to: isAuth,
|
|
||||||
jwtPresent: !!jwtToken,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
return isAuth;
|
return isAuth;
|
||||||
}
|
}
|
||||||
return prev; // No change, don't trigger re-render
|
return prev;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check immediately
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
// Reduced frequency - check every 5 seconds instead of every second
|
|
||||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||||
|
|
||||||
return () => clearInterval(authCheckInterval);
|
return () => clearInterval(authCheckInterval);
|
||||||
}, []); // No dependencies - prevent infinite loop
|
}, []);
|
||||||
|
|
||||||
function hardRefresh() {
|
function hardRefresh() {
|
||||||
try {
|
try {
|
||||||
@@ -139,8 +129,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
[terminal],
|
[terminal],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resize handling optimized to avoid conflicts - Linus principle: eliminate duplicate complexity
|
|
||||||
|
|
||||||
function handleWindowResize() {
|
function handleWindowResize() {
|
||||||
if (!isVisibleRef.current) return;
|
if (!isVisibleRef.current) return;
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
@@ -174,10 +162,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
else if (msg.type === "error")
|
else if (msg.type === "error")
|
||||||
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
|
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
|
||||||
else if (msg.type === "connected") {
|
else if (msg.type === "connected") {
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
} else if (msg.type === "disconnected") {
|
} else if (msg.type === "disconnected") {
|
||||||
wasDisconnectedBySSH.current = true;
|
wasDisconnectedBySSH.current = true;
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
terminal.writeln(
|
terminal.writeln(
|
||||||
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
||||||
);
|
);
|
||||||
@@ -186,17 +174,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
|
|
||||||
// Handle authentication errors (code 1008)
|
|
||||||
if (event.code === 1008) {
|
if (event.code === 1008) {
|
||||||
console.error("WebSocket authentication failed:", event.reason);
|
console.error("WebSocket authentication failed:", event.reason);
|
||||||
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
||||||
|
|
||||||
// Clear invalid JWT token
|
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
|
|
||||||
// Don't attempt to reconnect on auth failure
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +190,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("error", () => {
|
ws.addEventListener("error", () => {
|
||||||
isConnectingRef.current = false; // Clear connecting state
|
isConnectingRef.current = false;
|
||||||
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
|
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -214,9 +198,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||||
|
|
||||||
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
console.debug("Terminal setup delayed - waiting for authentication");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +213,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
@@ -271,7 +252,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
}, 150); // Increased debounce for better stability
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
resizeObserver.observe(xtermRef.current);
|
||||||
@@ -280,24 +261,22 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
(document as any).fonts?.ready instanceof Promise
|
(document as any).fonts?.ready instanceof Promise
|
||||||
? (document as any).fonts.ready
|
? (document as any).fonts.ready
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
// Show terminal immediately - better UX for mobile
|
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
|
||||||
readyFonts.then(() => {
|
readyFonts.then(() => {
|
||||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
|
||||||
// Verify authentication before attempting WebSocket connection
|
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
if (!jwtToken || jwtToken.trim() === "") {
|
if (!jwtToken || jwtToken.trim() === "") {
|
||||||
console.warn("WebSocket connection delayed - no authentication token");
|
console.warn(
|
||||||
|
"WebSocket connection delayed - no authentication token",
|
||||||
|
);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
setConnectionError("Authentication required");
|
setConnectionError("Authentication required");
|
||||||
// Don't show toast here - let auth system handle it
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,27 +304,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
// Prevent duplicate connections - Linus principle: fail fast
|
|
||||||
if (isConnectingRef.current) {
|
if (isConnectingRef.current) {
|
||||||
console.debug("Skipping connection - already connecting");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
|
|
||||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
if (
|
||||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
webSocketRef.current &&
|
||||||
console.log("Closing existing WebSocket connection before creating new one");
|
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||||
|
) {
|
||||||
webSocketRef.current.close();
|
webSocketRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing ping interval
|
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add JWT token as query parameter for authentication
|
|
||||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||||
|
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
@@ -356,7 +332,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
setupWebSocketListeners(ws, cols, rows);
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -369,7 +345,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
}
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && fitAddonRef.current) {
|
if (isVisible && fitAddonRef.current) {
|
||||||
|
|||||||
@@ -180,8 +180,6 @@ export function HomepageAuth({
|
|||||||
throw new Error(t("errors.loginFailed"));
|
throw new Error(t("errors.loginFailed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
|
||||||
console.log("Login successful - JWT set as secure HttpOnly cookie");
|
|
||||||
[meRes] = await Promise.all([getUserInfo()]);
|
[meRes] = await Promise.all([getUserInfo()]);
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -214,7 +212,6 @@ export function HomepageAuth({
|
|||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
|
|
||||||
if (err?.response?.data?.error?.includes("Database")) {
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
setDbError(t("errors.databaseConnection"));
|
setDbError(t("errors.databaseConnection"));
|
||||||
} else {
|
} else {
|
||||||
@@ -287,11 +284,12 @@ export function HomepageAuth({
|
|||||||
setResetSuccess(true);
|
setResetSuccess(true);
|
||||||
toast.success(t("messages.passwordResetSuccess"));
|
toast.success(t("messages.passwordResetSuccess"));
|
||||||
|
|
||||||
// Immediately redirect to login after successful reset
|
|
||||||
setTab("login");
|
setTab("login");
|
||||||
resetPasswordState();
|
resetPasswordState();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
toast.error(
|
||||||
|
err?.response?.data?.error || t("errors.failedCompleteReset"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -330,8 +328,6 @@ export function HomepageAuth({
|
|||||||
throw new Error(t("errors.loginFailed"));
|
throw new Error(t("errors.loginFailed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
|
||||||
console.log("TOTP login successful - JWT set as secure HttpOnly cookie");
|
|
||||||
const meRes = await getUserInfo();
|
const meRes = await getUserInfo();
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -400,8 +396,6 @@ export function HomepageAuth({
|
|||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
|
||||||
console.log("OIDC login successful - JWT set as secure HttpOnly cookie");
|
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -429,7 +423,6 @@ export function HomepageAuth({
|
|||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
|
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{},
|
{},
|
||||||
document.title,
|
document.title,
|
||||||
@@ -528,9 +521,7 @@ export function HomepageAuth({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!internalLoggedIn &&
|
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||||
!authLoading &&
|
|
||||||
!totpRequired && (
|
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
<button
|
<button
|
||||||
@@ -686,9 +677,7 @@ export function HomepageAuth({
|
|||||||
disabled={resetLoading || resetCode.length !== 6}
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
onClick={handleVerifyResetCode}
|
onClick={handleVerifyResetCode}
|
||||||
>
|
>
|
||||||
{resetLoading
|
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||||
? Spinner
|
|
||||||
: t("auth.verifyCodeButton")}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -706,7 +695,6 @@ export function HomepageAuth({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{resetStep === "newPassword" && !resetSuccess && (
|
{resetStep === "newPassword" && !resetSuccess && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
@@ -739,9 +727,7 @@ export function HomepageAuth({
|
|||||||
required
|
required
|
||||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
setConfirmPassword(e.target.value)
|
|
||||||
}
|
|
||||||
disabled={resetLoading}
|
disabled={resetLoading}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ const AppContent: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
// With HttpOnly cookies, we can't check for JWT presence from frontend
|
|
||||||
// Instead, we'll try to get user info and handle the response
|
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
@@ -34,9 +32,7 @@ const AppContent: FC = () => {
|
|||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
|
|
||||||
// Check if user data is unlocked
|
|
||||||
if (!meRes.data_unlocked) {
|
if (!meRes.data_unlocked) {
|
||||||
// Data is locked - user needs to re-authenticate
|
|
||||||
console.warn("User data is locked - re-authentication required");
|
console.warn("User data is locked - re-authentication required");
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -48,7 +44,6 @@ const AppContent: FC = () => {
|
|||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
|
|
||||||
// Check if this is a session expiration error
|
|
||||||
const errorCode = err?.response?.data?.code;
|
const errorCode = err?.response?.data?.code;
|
||||||
if (errorCode === "SESSION_EXPIRED") {
|
if (errorCode === "SESSION_EXPIRED") {
|
||||||
console.warn("Session expired - please log in again");
|
console.warn("Session expired - please log in again");
|
||||||
|
|||||||
@@ -86,17 +86,15 @@ export function DragIndicator({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
|
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Title */}
|
|
||||||
<div className="text-sm font-medium text-foreground mb-2">
|
<div className="text-sm font-medium text-foreground mb-2">
|
||||||
{fileCount > 1 ? t("dragIndicator.batchDrag") : t("dragIndicator.dragToDesktop")}
|
{fileCount > 1
|
||||||
|
? t("dragIndicator.batchDrag")
|
||||||
|
: t("dragIndicator.dragToDesktop")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status text */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs mb-3",
|
"text-xs mb-3",
|
||||||
@@ -110,7 +108,6 @@ export function DragIndicator({
|
|||||||
{getStatusText()}
|
{getStatusText()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{(isDownloading || isDragging) && !error && (
|
{(isDownloading || isDragging) && !error && (
|
||||||
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
|
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
|
||||||
<div
|
<div
|
||||||
@@ -123,14 +120,12 @@ export function DragIndicator({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress percentage */}
|
|
||||||
{(isDownloading || isDragging) && !error && (
|
{(isDownloading || isDragging) && !error && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{progress.toFixed(0)}%
|
{progress.toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drag hint */}
|
|
||||||
{isDragging && !error && (
|
{isDragging && !error && (
|
||||||
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
|
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
@@ -140,7 +135,6 @@ export function DragIndicator({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Background with animation effect */}
|
|
||||||
{isDragging && !error && (
|
{isDragging && !error && (
|
||||||
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
|
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function useDragToDesktop({
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if running in Electron environment
|
|
||||||
const isElectron = () => {
|
const isElectron = () => {
|
||||||
return (
|
return (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -41,13 +40,13 @@ export function useDragToDesktop({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag single file to desktop
|
|
||||||
const dragFileToDesktop = useCallback(
|
const dragFileToDesktop = useCallback(
|
||||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||||
const { enableToast = true, onSuccess, onError } = options;
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
if (!isElectron()) {
|
if (!isElectron()) {
|
||||||
const error = "Drag to desktop feature is only available in desktop application";
|
const error =
|
||||||
|
"Drag to desktop feature is only available in desktop application";
|
||||||
if (enableToast) toast.error(error);
|
if (enableToast) toast.error(error);
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -68,7 +67,6 @@ export function useDragToDesktop({
|
|||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Download file content
|
|
||||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
|
|
||||||
if (!response?.content) {
|
if (!response?.content) {
|
||||||
@@ -77,7 +75,6 @@ export function useDragToDesktop({
|
|||||||
|
|
||||||
setState((prev) => ({ ...prev, progress: 50 }));
|
setState((prev) => ({ ...prev, progress: 50 }));
|
||||||
|
|
||||||
// Create temporary file
|
|
||||||
const tempResult = await window.electronAPI.createTempFile({
|
const tempResult = await window.electronAPI.createTempFile({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
content: response.content,
|
content: response.content,
|
||||||
@@ -85,12 +82,13 @@ export function useDragToDesktop({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!tempResult.success) {
|
if (!tempResult.success) {
|
||||||
throw new Error(tempResult.error || "Failed to create temporary file");
|
throw new Error(
|
||||||
|
tempResult.error || "Failed to create temporary file",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||||
|
|
||||||
// Start dragging
|
|
||||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||||
tempId: tempResult.tempId,
|
tempId: tempResult.tempId,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -108,7 +106,6 @@ export function useDragToDesktop({
|
|||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Delayed cleanup of temporary file (give user time to complete drag)
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
@@ -117,7 +114,7 @@ export function useDragToDesktop({
|
|||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
}));
|
}));
|
||||||
}, 10000); // Cleanup after 10 seconds
|
}, 10000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -143,13 +140,13 @@ export function useDragToDesktop({
|
|||||||
[sshSessionId, sshHost],
|
[sshSessionId, sshHost],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag multiple files to desktop (batch operation)
|
|
||||||
const dragFilesToDesktop = useCallback(
|
const dragFilesToDesktop = useCallback(
|
||||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||||
const { enableToast = true, onSuccess, onError } = options;
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
if (!isElectron()) {
|
if (!isElectron()) {
|
||||||
const error = "Drag to desktop feature is only available in desktop application";
|
const error =
|
||||||
|
"Drag to desktop feature is only available in desktop application";
|
||||||
if (enableToast) toast.error(error);
|
if (enableToast) toast.error(error);
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -175,7 +172,6 @@ export function useDragToDesktop({
|
|||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Batch download files
|
|
||||||
const downloadPromises = fileList.map((file) =>
|
const downloadPromises = fileList.map((file) =>
|
||||||
downloadSSHFile(sshSessionId, file.path),
|
downloadSSHFile(sshSessionId, file.path),
|
||||||
);
|
);
|
||||||
@@ -183,7 +179,6 @@ export function useDragToDesktop({
|
|||||||
const responses = await Promise.all(downloadPromises);
|
const responses = await Promise.all(downloadPromises);
|
||||||
setState((prev) => ({ ...prev, progress: 40 }));
|
setState((prev) => ({ ...prev, progress: 40 }));
|
||||||
|
|
||||||
// Create temporary folder structure
|
|
||||||
const folderName = `Files_${Date.now()}`;
|
const folderName = `Files_${Date.now()}`;
|
||||||
const filesData = fileList.map((file, index) => ({
|
const filesData = fileList.map((file, index) => ({
|
||||||
relativePath: file.name,
|
relativePath: file.name,
|
||||||
@@ -197,12 +192,13 @@ export function useDragToDesktop({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!tempResult.success) {
|
if (!tempResult.success) {
|
||||||
throw new Error(tempResult.error || "Failed to create temporary folder");
|
throw new Error(
|
||||||
|
tempResult.error || "Failed to create temporary folder",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||||
|
|
||||||
// Start dragging folder
|
|
||||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||||
tempId: tempResult.tempId,
|
tempId: tempResult.tempId,
|
||||||
fileName: folderName,
|
fileName: folderName,
|
||||||
@@ -220,7 +216,6 @@ export function useDragToDesktop({
|
|||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Delayed cleanup of temporary folder
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
@@ -229,8 +224,7 @@ export function useDragToDesktop({
|
|||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
}));
|
}));
|
||||||
}, 15000); // Cleanup after 15 seconds
|
}, 15000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to batch drag to desktop:", error);
|
console.error("Failed to batch drag to desktop:", error);
|
||||||
@@ -255,13 +249,13 @@ export function useDragToDesktop({
|
|||||||
[sshSessionId, sshHost, dragFileToDesktop],
|
[sshSessionId, sshHost, dragFileToDesktop],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag folder to desktop
|
|
||||||
const dragFolderToDesktop = useCallback(
|
const dragFolderToDesktop = useCallback(
|
||||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||||
const { enableToast = true, onSuccess, onError } = options;
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
|
|
||||||
if (!isElectron()) {
|
if (!isElectron()) {
|
||||||
const error = "Drag to desktop feature is only available in desktop application";
|
const error =
|
||||||
|
"Drag to desktop feature is only available in desktop application";
|
||||||
if (enableToast) toast.error(error);
|
if (enableToast) toast.error(error);
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
return false;
|
return false;
|
||||||
@@ -278,9 +272,6 @@ export function useDragToDesktop({
|
|||||||
toast.info("Folder drag functionality is under development...");
|
toast.info("Folder drag functionality is under development...");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement recursive folder download and drag
|
|
||||||
// This requires additional API to recursively get folder contents
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[sshSessionId, sshHost],
|
[sshSessionId, sshHost],
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export function useDragToSystemDesktop({
|
|||||||
options: DragToSystemOptions;
|
options: DragToSystemOptions;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Directory memory functionality
|
|
||||||
const getLastSaveDirectory = async () => {
|
const getLastSaveDirectory = async () => {
|
||||||
try {
|
try {
|
||||||
if ("indexedDB" in window) {
|
if ("indexedDB" in window) {
|
||||||
@@ -60,9 +59,7 @@ export function useDragToSystemDesktop({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
console.log("Unable to get last save directory:", error);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,19 +75,15 @@ export function useDragToSystemDesktop({
|
|||||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
console.log("Unable to save directory record:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check File System Access API support
|
|
||||||
const isFileSystemAPISupported = () => {
|
const isFileSystemAPISupported = () => {
|
||||||
return "showSaveFilePicker" in window;
|
return "showSaveFilePicker" in window;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if drag has left window boundaries
|
|
||||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||||
const margin = 50; // Increase tolerance margin
|
const margin = 50;
|
||||||
return (
|
return (
|
||||||
e.clientX < margin ||
|
e.clientX < margin ||
|
||||||
e.clientX > window.innerWidth - margin ||
|
e.clientX > window.innerWidth - margin ||
|
||||||
@@ -99,14 +92,12 @@ export function useDragToSystemDesktop({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create file blob
|
|
||||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||||
if (!response?.content) {
|
if (!response?.content) {
|
||||||
throw new Error(`Unable to get content for file ${file.name}`);
|
throw new Error(`Unable to get content for file ${file.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert base64 to blob
|
|
||||||
const binaryString = atob(response.content);
|
const binaryString = atob(response.content);
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
@@ -116,9 +107,7 @@ export function useDragToSystemDesktop({
|
|||||||
return new Blob([bytes]);
|
return new Blob([bytes]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create ZIP file (for multi-file download)
|
|
||||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||||
// A lightweight zip library is needed here, using simple approach for now
|
|
||||||
const JSZip = (await import("jszip")).default;
|
const JSZip = (await import("jszip")).default;
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
@@ -130,8 +119,6 @@ export function useDragToSystemDesktop({
|
|||||||
return await zip.generateAsync({ type: "blob" });
|
return await zip.generateAsync({ type: "blob" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Fallback solution: traditional download
|
|
||||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -143,7 +130,6 @@ export function useDragToSystemDesktop({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag to system desktop
|
|
||||||
const handleDragToSystem = useCallback(
|
const handleDragToSystem = useCallback(
|
||||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||||
const { enableToast = true, onSuccess, onError } = options;
|
const { enableToast = true, onSuccess, onError } = options;
|
||||||
@@ -155,7 +141,6 @@ export function useDragToSystemDesktop({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out file types
|
|
||||||
const fileList = files.filter((f) => f.type === "file");
|
const fileList = files.filter((f) => f.type === "file");
|
||||||
if (fileList.length === 0) {
|
if (fileList.length === 0) {
|
||||||
const error = "Only files can be dragged to desktop";
|
const error = "Only files can be dragged to desktop";
|
||||||
@@ -172,12 +157,9 @@ export function useDragToSystemDesktop({
|
|||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Determine file name first (synchronously)
|
const fileName =
|
||||||
const fileName = fileList.length === 1
|
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
|
||||||
? 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;
|
let fileHandle: any = null;
|
||||||
if (isFileSystemAPISupported()) {
|
if (isFileSystemAPISupported()) {
|
||||||
try {
|
try {
|
||||||
@@ -188,14 +170,21 @@ export function useDragToSystemDesktop({
|
|||||||
{
|
{
|
||||||
description: "Files",
|
description: "Files",
|
||||||
accept: {
|
accept: {
|
||||||
"*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
|
"*/*": [
|
||||||
|
".txt",
|
||||||
|
".jpg",
|
||||||
|
".png",
|
||||||
|
".pdf",
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
// User cancelled
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
@@ -207,32 +196,28 @@ export function useDragToSystemDesktop({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now create the blob (after getting file handle)
|
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
if (fileList.length === 1) {
|
if (fileList.length === 1) {
|
||||||
// Single file
|
|
||||||
blob = await createFileBlob(fileList[0]);
|
blob = await createFileBlob(fileList[0]);
|
||||||
setState((prev) => ({ ...prev, progress: 70 }));
|
setState((prev) => ({ ...prev, progress: 70 }));
|
||||||
} else {
|
} else {
|
||||||
// Package multiple files into ZIP
|
|
||||||
blob = await createZipBlob(fileList);
|
blob = await createZipBlob(fileList);
|
||||||
setState((prev) => ({ ...prev, progress: 70 }));
|
setState((prev) => ({ ...prev, progress: 70 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, progress: 90 }));
|
setState((prev) => ({ ...prev, progress: 90 }));
|
||||||
|
|
||||||
// Save the file
|
|
||||||
if (fileHandle) {
|
if (fileHandle) {
|
||||||
// Use File System Access API with pre-obtained handle
|
|
||||||
await saveLastDirectory(fileHandle);
|
await saveLastDirectory(fileHandle);
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(blob);
|
await writable.write(blob);
|
||||||
await writable.close();
|
await writable.close();
|
||||||
} else {
|
} else {
|
||||||
// Fallback to traditional download
|
|
||||||
fallbackDownload(blob, fileName);
|
fallbackDownload(blob, fileName);
|
||||||
if (enableToast) {
|
if (enableToast) {
|
||||||
toast.info("Due to browser limitations, file will be downloaded to default download directory");
|
toast.info(
|
||||||
|
"Due to browser limitations, file will be downloaded to default download directory",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,14 +233,12 @@ export function useDragToSystemDesktop({
|
|||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Reset state
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to drag to desktop:", error);
|
|
||||||
const errorMessage = error.message || "Save failed";
|
const errorMessage = error.message || "Save failed";
|
||||||
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
@@ -276,7 +259,6 @@ export function useDragToSystemDesktop({
|
|||||||
[sshSessionId],
|
[sshSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start dragging (record drag data)
|
|
||||||
const startDragToSystem = useCallback(
|
const startDragToSystem = useCallback(
|
||||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||||
dragDataRef.current = { files, options };
|
dragDataRef.current = { files, options };
|
||||||
@@ -285,27 +267,22 @@ export function useDragToSystemDesktop({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// End drag detection
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(e: DragEvent) => {
|
(e: DragEvent) => {
|
||||||
if (!dragDataRef.current) return;
|
if (!dragDataRef.current) return;
|
||||||
|
|
||||||
const { files, options } = dragDataRef.current;
|
const { files, options } = dragDataRef.current;
|
||||||
|
|
||||||
// Check if dragged outside window
|
|
||||||
if (isDraggedOutsideWindow(e)) {
|
if (isDraggedOutsideWindow(e)) {
|
||||||
// Execute immediately to preserve user gesture context for showSaveFilePicker
|
|
||||||
handleDragToSystem(files, options);
|
handleDragToSystem(files, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up drag state
|
|
||||||
dragDataRef.current = null;
|
dragDataRef.current = null;
|
||||||
setState((prev) => ({ ...prev, isDragging: false }));
|
setState((prev) => ({ ...prev, isDragging: false }));
|
||||||
},
|
},
|
||||||
[handleDragToSystem],
|
[handleDragToSystem],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel dragging
|
|
||||||
const cancelDragToSystem = useCallback(() => {
|
const cancelDragToSystem = useCallback(() => {
|
||||||
dragDataRef.current = null;
|
dragDataRef.current = null;
|
||||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||||
@@ -317,6 +294,6 @@ export function useDragToSystemDesktop({
|
|||||||
startDragToSystem,
|
startDragToSystem,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
cancelDragToSystem,
|
cancelDragToSystem,
|
||||||
handleDragToSystem, // Direct call version
|
handleDragToSystem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-38
@@ -115,8 +115,6 @@ export function setCookie(name: string, value: string, days = 7): void {
|
|||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
localStorage.setItem(name, value);
|
localStorage.setItem(name, value);
|
||||||
} else {
|
} else {
|
||||||
// Note: For secure authentication, cookies should be set by the backend
|
|
||||||
// This function is kept for backward compatibility with non-auth cookies
|
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
}
|
}
|
||||||
@@ -131,7 +129,6 @@ export function getCookie(name: string): string | undefined {
|
|||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
const encodedToken =
|
const encodedToken =
|
||||||
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
|
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
|
||||||
// Decode the token since setCookie uses encodeURIComponent
|
|
||||||
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
|
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -271,20 +268,15 @@ function createApiInstance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// Check if this is a session expiration (data lock) vs regular auth failure
|
|
||||||
const errorCode = (error.response?.data as any)?.code;
|
const errorCode = (error.response?.data as any)?.code;
|
||||||
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
||||||
|
|
||||||
// Clear authentication state
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
} else {
|
} else {
|
||||||
// For web, the secure HttpOnly cookie will be cleared by the backend
|
|
||||||
// We can't clear HttpOnly cookies from JavaScript
|
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If session expired, show notification and reload page
|
|
||||||
if (isSessionExpired && typeof window !== "undefined") {
|
if (isSessionExpired && typeof window !== "undefined") {
|
||||||
console.warn("Session expired - please log in again");
|
console.warn("Session expired - please log in again");
|
||||||
|
|
||||||
@@ -292,12 +284,10 @@ function createApiInstance(
|
|||||||
toast.warning("Session expired - please log in again");
|
toast.warning("Session expired - please log in again");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger a page reload to redirect to login
|
|
||||||
setTimeout(() => window.location.reload(), 100);
|
setTimeout(() => window.location.reload(), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -441,7 +431,6 @@ function getApiUrl(path: string, defaultPort: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize API instances
|
|
||||||
function initializeApiInstances() {
|
function initializeApiInstances() {
|
||||||
// SSH Host Management API (port 30001)
|
// SSH Host Management API (port 30001)
|
||||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
||||||
@@ -477,7 +466,6 @@ export let statsApi: AxiosInstance;
|
|||||||
// Authentication API (port 30001)
|
// Authentication API (port 30001)
|
||||||
export let authApi: AxiosInstance;
|
export let authApi: AxiosInstance;
|
||||||
|
|
||||||
// Initialize API instances immediately
|
|
||||||
initializeApiInstances();
|
initializeApiInstances();
|
||||||
|
|
||||||
function updateApiInstances() {
|
function updateApiInstances() {
|
||||||
@@ -488,7 +476,6 @@ function updateApiInstances() {
|
|||||||
|
|
||||||
initializeApiInstances();
|
initializeApiInstances();
|
||||||
|
|
||||||
// Make configuredServerUrl available globally for components that need it
|
|
||||||
(window as any).configuredServerUrl = configuredServerUrl;
|
(window as any).configuredServerUrl = configuredServerUrl;
|
||||||
|
|
||||||
systemLogger.success("All API instances updated successfully", {
|
systemLogger.success("All API instances updated successfully", {
|
||||||
@@ -587,7 +574,6 @@ function handleApiError(error: unknown, operation: string): never {
|
|||||||
"SERVER_ERROR",
|
"SERVER_ERROR",
|
||||||
);
|
);
|
||||||
} else if (status === 0) {
|
} else if (status === 0) {
|
||||||
// Check if this is a "no server configured" error
|
|
||||||
if (url.includes("no-server-configured")) {
|
if (url.includes("no-server-configured")) {
|
||||||
apiLogger.error(
|
apiLogger.error(
|
||||||
`No server configured: ${method} ${url}`,
|
`No server configured: ${method} ${url}`,
|
||||||
@@ -796,7 +782,9 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
|||||||
|
|
||||||
export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.post("/autostart/enable", { sshConfigId });
|
const response = await sshHostApi.post("/autostart/enable", {
|
||||||
|
sshConfigId,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "enable autostart");
|
handleApiError(error, "enable autostart");
|
||||||
@@ -806,7 +794,7 @@ export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
|||||||
export async function disableAutoStart(sshConfigId: number): Promise<any> {
|
export async function disableAutoStart(sshConfigId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete("/autostart/disable", {
|
const response = await sshHostApi.delete("/autostart/disable", {
|
||||||
data: { sshConfigId }
|
data: { sshConfigId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1072,7 +1060,7 @@ export async function listSSHFiles(
|
|||||||
return response.data || { files: [], path };
|
return response.data || { files: [], path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "list SSH files");
|
handleApiError(error, "list SSH files");
|
||||||
return { files: [], path }; // Ensure always return correct format
|
return { files: [], path };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,11 +1088,11 @@ export async function readSSHFile(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Preserve fileNotFound information for 404 errors
|
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
const customError = new Error("File not found");
|
const customError = new Error("File not found");
|
||||||
(customError as any).response = error.response;
|
(customError as any).response = error.response;
|
||||||
(customError as any).isFileNotFound = error.response.data?.fileNotFound || true;
|
(customError as any).isFileNotFound =
|
||||||
|
error.response.data?.fileNotFound || true;
|
||||||
throw customError;
|
throw customError;
|
||||||
}
|
}
|
||||||
handleApiError(error, "read SSH file");
|
handleApiError(error, "read SSH file");
|
||||||
@@ -1268,7 +1256,7 @@ export async function copySSHItem(
|
|||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: 60000, // 60 second timeout as file copying may take longer
|
timeout: 60000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1308,15 +1296,19 @@ export async function moveSSHItem(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.put("/ssh/moveItem", {
|
const response = await fileManagerApi.put(
|
||||||
|
"/ssh/moveItem",
|
||||||
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
oldPath,
|
oldPath,
|
||||||
newPath,
|
newPath,
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
userId,
|
||||||
}, {
|
},
|
||||||
timeout: 60000, // 60 second timeout for move operations
|
{
|
||||||
});
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "move SSH item");
|
handleApiError(error, "move SSH item");
|
||||||
@@ -1374,7 +1366,6 @@ export async function removeRecentFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pinned Files
|
|
||||||
export async function getPinnedFiles(hostId: number): Promise<any> {
|
export async function getPinnedFiles(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/file_manager/pinned", {
|
const response = await authApi.get("/ssh/file_manager/pinned", {
|
||||||
@@ -1420,7 +1411,6 @@ export async function removePinnedFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder Shortcuts
|
|
||||||
export async function getFolderShortcuts(hostId: number): Promise<any> {
|
export async function getFolderShortcuts(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
||||||
@@ -1524,10 +1514,8 @@ export async function loginUser(
|
|||||||
): Promise<AuthResponse> {
|
): Promise<AuthResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/login", { username, password });
|
const response = await authApi.post("/users/login", { username, password });
|
||||||
// JWT token is now set as secure HttpOnly cookie by backend
|
|
||||||
// Return success status and user info
|
|
||||||
return {
|
return {
|
||||||
token: "cookie-based", // Placeholder since token is in HttpOnly cookie
|
token: "cookie-based",
|
||||||
success: response.data.success,
|
success: response.data.success,
|
||||||
is_admin: response.data.is_admin,
|
is_admin: response.data.is_admin,
|
||||||
username: response.data.username,
|
username: response.data.username,
|
||||||
@@ -1537,7 +1525,10 @@ export async function loginUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutUser(): Promise<{ success: boolean; message: string }> {
|
export async function logoutUser(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/logout");
|
const response = await authApi.post("/users/logout");
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1555,7 +1546,9 @@ export async function getUserInfo(): Promise<UserInfo> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlockUserData(password: string): Promise<{ success: boolean; message: string }> {
|
export async function unlockUserData(
|
||||||
|
password: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/unlock-data", { password });
|
const response = await authApi.post("/users/unlock-data", { password });
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1824,9 +1817,7 @@ export async function getUserAlerts(): Promise<{ alerts: any[] }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dismissAlert(
|
export async function dismissAlert(alertId: string): Promise<any> {
|
||||||
alertId: string,
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1943,7 +1934,6 @@ export async function getCredentialFolders(): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get SSH host with resolved credentials
|
|
||||||
export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(
|
const response = await sshHostApi.get(
|
||||||
@@ -1955,7 +1945,6 @@ export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply credential to SSH host
|
|
||||||
export async function applyCredentialToHost(
|
export async function applyCredentialToHost(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
credentialId: number,
|
credentialId: number,
|
||||||
@@ -1971,7 +1960,6 @@ export async function applyCredentialToHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove credential from SSH host
|
|
||||||
export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||||
@@ -1981,7 +1969,6 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate host to managed credential
|
|
||||||
export async function migrateHostToCredential(
|
export async function migrateHostToCredential(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
credentialName: string,
|
credentialName: string,
|
||||||
|
|||||||
+4
-2
@@ -22,10 +22,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
base: "./",
|
base: "./",
|
||||||
server: {
|
server: {
|
||||||
https: useHTTPS ? {
|
https: useHTTPS
|
||||||
|
? {
|
||||||
cert: fs.readFileSync(sslCertPath),
|
cert: fs.readFileSync(sslCertPath),
|
||||||
key: fs.readFileSync(sslKeyPath),
|
key: fs.readFileSync(sslKeyPath),
|
||||||
} : false,
|
}
|
||||||
|
: false,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user