v1.7.0 #318

Merged
LukeGus merged 138 commits from dev-1.7.0 into main 2025-10-01 20:40:10 +00:00
76 changed files with 62289 additions and 6806 deletions
Showing only changes of commit bc8aa69099 - Show all commits
+2 -2
View File
@@ -77,7 +77,7 @@ jobs:
run: | run: |
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
IMAGE_TAG="${{ github.event.inputs.tag_name }}" IMAGE_TAG="${{ github.event.inputs.tag_name }}"
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
@@ -88,7 +88,7 @@ jobs:
IMAGE_TAG="${{ github.ref_name }}" IMAGE_TAG="${{ github.ref_name }}"
fi fi
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Determine registry and image name # Determine registry and image name
if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then
echo "REGISTRY=docker.io" >> $GITHUB_ENV echo "REGISTRY=docker.io" >> $GITHUB_ENV
-49
View File
@@ -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)
-38
View File
@@ -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
+5 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+2 -202
View File
1
@@ -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 {
@@ -183,8 +177,7 @@ 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(),
1
@@ -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...");
}); });
-19
View File
@@ -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");
+58103 -4
View File
File diff suppressed because one or more lines are too long
+7 -77
View File
@@ -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
View File
@@ -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 "$@"
File diff suppressed because it is too large Load Diff
+40 -236
View File
@@ -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();
databaseLogger.info("SystemCrypto database key verified", {
operation: "db_init_systemcrypto_complete",
keyLength: dbKey.length,
keyAvailable: !!dbKey,
});
const dbKey = await systemCrypto.getDatabaseKey();
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) { } catch (error) {}
// Ignore temp directory cleanup errors
}
} }
// 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 };
-600
View File
@@ -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 };
+1 -3
View File
@@ -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`),
}); });
-3
View File
@@ -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) => {
File diff suppressed because it is too large Load Diff
+108 -135
View File
@@ -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(
source: req.ip, "Unauthorized attempt to access internal SSH host endpoint",
userAgent: req.headers["user-agent"], {
providedToken: internalToken ? "present" : "missing" source: req.ip,
}); userAgent: req.headers["user-agent"],
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(
operation: "autostart_enable", "Missing or invalid sshConfigId in autostart enable request",
userId, {
sshConfigId operation: "autostart_enable",
}); userId,
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(
operation: "autostart_enable_failed", "User attempted to enable autostart without unlocked data",
userId, {
sshConfigId, operation: "autostart_enable_failed",
reason: "data_locked" userId,
}); sshConfigId,
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.name === tunnel.endpointHost || (h) =>
`${h.username}@${h.ip}` === tunnel.endpointHost h.name === 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(
operation: "autostart_disable", "Missing or invalid sshConfigId in autostart disable request",
userId, {
sshConfigId operation: "autostart_disable",
}); userId,
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(
eq(sshData.userId, userId), and(
or( eq(sshData.userId, userId),
isNotNull(sshData.autostartPassword), or(
isNotNull(sshData.autostartKey) isNotNull(sshData.autostartPassword),
) 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;
+87 -89
View File
@@ -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(
operation: "user_create_encryption_failed", "Failed to setup user encryption, user creation rolled back",
userId: id, encryptionError,
}); {
operation: "user_create_encryption_failed",
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(
operation: "oidc_config_basic_encoding", "OIDC configuration stored with basic encoding - admin should re-save with password",
userId, {
}); operation: "oidc_config_basic_encoding",
userId,
},
);
} }
} catch (encryptError) { } catch (encryptError) {
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, { authLogger.error(
operation: "oidc_config_encrypt_failed", "Failed to encrypt OIDC configuration, storing with basic encoding",
userId, encryptError,
}); {
operation: "oidc_config_encrypt_failed",
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,19 +941,16 @@ 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",
userId, userId,
}); });
} }
// 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,9 +975,8 @@ 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({
userId: user[0].id, userId: user[0].id,
username: user[0].username, username: user[0].username,
@@ -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", {
@@ -1845,12 +1843,14 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
// GET /users/data-status // GET /users/data-status
router.get("/data-status", authenticateJWT, async (req, res) => { router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as any).userId; const userId = (req as any).userId;
try { try {
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", {
@@ -1921,4 +1919,4 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
} }
}); });
export default router; export default router;
+205 -304
View File
@@ -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) {
1
@@ -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,20 +602,18 @@ 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("cannot access") || errorLower.includes("no such file or directory") ||
errorLower.includes("not found") || errorLower.includes("cannot access") ||
errorLower.includes("resource not found"); errorLower.includes("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) error: `Cannot check file size: ${sizeErrorData}`,
.json({ fileNotFound: isFileNotFound,
error: `Cannot check file size: ${sizeErrorData}`, });
fileNotFound: isFileNotFound
});
} }
const fileSize = parseInt(sizeData.trim(), 10); const fileSize = parseInt(sizeData.trim(), 10);
@@ -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,18 +662,15 @@ 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) error: `Command failed: ${errorData}`,
.json({ fileNotFound: isFileNotFound,
error: `Command failed: ${errorData}`, });
fileNotFound: isFileNotFound
});
} }
res.json({ content: data, path: filePath }); res.json({ content: data, path: filePath });
@@ -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,96 +1826,57 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
try { const sourceName = sourcePath.split("/").pop() || "copied_item";
// Extract source name
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", { const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
originalName: sourceName, const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
uniqueName,
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath, sourcePath,
targetPath, targetPath,
sessionId, command: copyCommand,
}); });
if (!res.headersSent) {
// Escape paths for shell commands res.status(500).json({
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'"); error: "Copy operation timed out",
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'"); toast: {
type: "error",
// Linus principle: simplify - use basic cp command for reliability message: "Copy operation timed out. SSH connection may be unstable.",
// Just copy the file without complex flags that might cause issues },
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
fileLogger.info("Starting file copy operation", {
operation: "file_copy_start",
sessionId,
sourcePath,
targetPath,
uniqueName,
command: copyCommand.substring(0, 200) + "...", // Log truncated command
});
// Add timeout to prevent hanging
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath,
targetPath,
command: copyCommand,
}); });
}
}, 60000);
sshConn.client.exec(copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ return res.status(500).json({ error: err.message });
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
} }
}, 60000); // 60 second timeout for large files return;
}
sshConn.client.exec(copyCommand, (err, stream) => { let errorData = "";
if (err) { let stdoutData = "";
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let errorData = "";
let stdoutData = "";
// Monitor both stdout and stderr
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
fileLogger.info("Copy command stdout", {
output: output.substring(0, 200),
});
});
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
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,168 +1969,124 @@ 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 process.on("SIGINT", () => {
function getMimeType(fileName: string): string { Object.keys(sshSessions).forEach(cleanupSession);
const ext = fileName.split(".").pop()?.toLowerCase(); process.exit(0);
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("SIGTERM", () => {
Object.keys(sshSessions).forEach(cleanupSession); Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0); process.exit(0);
}); });
process.on("SIGTERM", () => { app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
Object.keys(sshSessions).forEach(cleanupSession); const { sessionId, filePath, hostId, userId } = req.body;
process.exit(0); const sshConn = sshSessions[sessionId];
});
// Execute executable file if (!sshConn || !sshConn.isConnected) {
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { fileLogger.error(
const { sessionId, filePath, hostId, userId } = req.body; "SSH connection not found or not connected for executeFile",
const sshConn = sshSessions[sessionId]; {
operation: "execute_file",
if (!sshConn || !sshConn.isConnected) { sessionId,
fileLogger.error( hasConnection: !!sshConn,
"SSH connection not found or not connected for executeFile", isConnected: sshConn?.isConnected,
{ },
operation: "execute_file", );
sessionId, return res.status(400).json({ error: "SSH connection not available" });
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
// Check if file exists and is executable
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
} }
let checkResult = ""; if (!filePath) {
checkStream.on("data", (data) => { return res.status(400).json({ error: "File path is required" });
checkResult += data.toString(); }
});
checkStream.on("close", (code) => { const escapedPath = filePath.replace(/'/g, "'\"'\"'");
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" }); const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
} }
// Execute file let checkResult = "";
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; checkStream.on("data", (data) => {
checkResult += data.toString();
fileLogger.info("Executing file", {
operation: "execute_file",
sessionId,
filePath,
command: executeCommand.substring(0, 100) + "...",
}); });
sshConn.client.exec(executeCommand, (err, stream) => { checkStream.on("close", (code) => {
if (err) { if (!checkResult.includes("EXECUTABLE")) {
fileLogger.error("SSH executeFile error:", err); return res.status(400).json({ error: "File is not executable" });
return res.status(500).json({ error: "Failed to execute file" });
} }
let output = ""; const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
let errorOutput = "";
stream.on("data", (data) => { sshConn.client.exec(executeCommand, (err, stream) => {
output += data.toString(); if (err) {
}); fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
// Extract exit code from output
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", {
operation: "execute_file",
sessionId,
filePath,
exitCode: actualExitCode,
outputLength: cleanOutput.length,
errorLength: errorOutput.length,
});
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
} }
let output = "";
let errorOutput = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", {
operation: "execute_file",
sessionId,
filePath,
exitCode: actualExitCode,
outputLength: cleanOutput.length,
errorLength: errorOutput.length,
});
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
}); });
}); });
}); });
}); });
});
const PORT = 30004; const PORT = 30004;
app.listen(PORT, async () => { app.listen(PORT, async () => {
fileLogger.success("File Manager API server started", { try {
operation: "server_start", await authManager.initialize();
port: PORT, } catch (err) {
fileLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
}); });
// Initialize AuthManager for JWT verification
try {
await authManager.initialize();
fileLogger.info("AuthManager initialized for file manager", {
operation: "auth_init",
});
} catch (err) {
fileLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
}); });
+27 -31
View File
@@ -282,30 +282,30 @@ app.use(
origin: (origin, callback) => { origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests) // 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 // 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) // 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 // 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 // Check against allowed development origins
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
return callback(null, true); return callback(null, true);
} }
// Reject other origins // Reject other origins
callback(new Error("Not allowed by CORS")); callback(new Error("Not allowed by CORS"));
}, },
@@ -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",
});
}); });
+64 -78
View File
@@ -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(
operation: "websocket_auth_reject", "WebSocket connection rejected: TOTP verification pending",
reason: "totp_pending", {
userId: payload.userId, operation: "websocket_auth_reject",
ip: info.req.socket.remoteAddress reason: "totp_pending",
}); userId: payload.userId,
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(
operation: "websocket_connection_reject", "WebSocket connection rejected: missing token in connection",
reason: "missing_token", {
ip: req.socket.remoteAddress operation: "websocket_connection_reject",
}); reason: "missing_token",
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(
operation: "websocket_connection_reject", "WebSocket connection rejected: invalid token in connection",
reason: "invalid_token", {
ip: req.socket.remoteAddress operation: "websocket_connection_reject",
}); reason: "invalid_token",
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(
operation: "websocket_connection_auth_error", "WebSocket JWT verification failed during connection",
ip: req.socket.remoteAddress error,
}); {
operation: "websocket_connection_auth_error",
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(
type: "error", JSON.stringify({
message: "Data locked - re-authenticate with password", type: "error",
code: "DATA_LOCKED" message: "Data locked - re-authenticate with password",
})); 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(
type: "error", JSON.stringify({
message: "Data access expired - please re-authenticate", type: "error",
code: "DATA_EXPIRED" message: "Data access expired - please re-authenticate",
})); 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"],
}, },
}; };
+112 -121
View File
@@ -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);
+43 -103
View File
@@ -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(
operation: "security_warning", "JWT_SECRET not set - using auto-generated keys (consider setting for production)",
note: "Auto-generated keys are secure but not persistent across deployments" {
}); operation: "security_warning",
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(
operation: "security_warning", "DATABASE_KEY not set - using auto-generated keys (consider setting for production)",
note: "Auto-generated keys are secure but not persistent across deployments" {
}); operation: "security_warning",
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(
operation: "security_warning", "INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)",
note: "Auto-generated tokens are secure but not persistent across deployments" {
}); operation: "security_warning",
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...",
+54 -122
View File
@@ -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(
operation: "lazy_encryption_migration_no_key", "Cannot perform lazy encryption migration - user data key not available",
userId, {
}); operation: "lazy_encryption_migration_no_key",
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(
operation: "admin_access_denied", "Non-admin user attempted to access admin endpoint",
userId: payload.userId, {
endpoint: req.path, operation: "admin_access_denied",
}); userId: payload.userId,
endpoint: req.path,
},
);
return res.status(403).json({ error: "Admin access required" }); return res.status(403).json({ error: "Admin access required" });
} }
@@ -317,38 +251,36 @@ 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,
);
} }
} }
export { AuthManager, type AuthenticationResult, type JWTPayload }; export { AuthManager, type AuthenticationResult, type JWTPayload };
+89 -147
View File
@@ -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(
operation: "ssl_cert_expired", "SSL certificate is expired or expiring soon, will regenerate",
cert_path: this.CERT_FILE, {
error: error.message operation: "ssl_cert_expired",
}); cert_path: this.CERT_FILE,
error: error.message,
},
);
} else { } else {
systemLogger.info("SSL certificate not found or invalid, will generate new one", { systemLogger.info(
operation: "ssl_cert_missing", "SSL certificate not found or invalid, will generate new one",
cert_path: this.CERT_FILE {
}); operation: "ssl_cert_missing",
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 ║
╚══════════════════════════════════════════════════════════════╝
`);
}
}
}
+104 -133
View File
@@ -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, userId,
}); userDataKey,
db,
// Check if migration is needed );
const { needsMigration, plaintextFields } = await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
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");
record, const { updatedRecord, migratedFields, needsUpdate } =
sensitiveFields, LazyFieldEncryption.migrateRecordSensitiveFields(
userDataKey, record,
record.id.toString() sensitiveFields,
); userDataKey,
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");
record, const { updatedRecord, migratedFields, needsUpdate } =
sensitiveFields, LazyFieldEncryption.migrateRecordSensitiveFields(
userDataKey, record,
record.id.toString() sensitiveFields,
); userDataKey,
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");
userRecord, const { updatedRecord, migratedFields, needsUpdate } =
sensitiveFields, LazyFieldEncryption.migrateRecordSensitiveFields(
userDataKey, userRecord,
userId sensitiveFields,
); userDataKey,
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) {
@@ -310,4 +281,4 @@ class DataCrypto {
} }
} }
export { DataCrypto }; export { DataCrypto };
+39 -95
View File
@@ -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", {
path: encryptedPath operation: "decrypt_legacy_v1",
}); 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", {
path: encryptedPath operation: "decrypt_legacy_v1",
}); 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 = [
+106 -159
View File
@@ -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(
operation: "migration_verify_failed", "Table count mismatch during migration verification",
originalCount: originalTables.length, null,
memoryCount: memoryTables.length, {
}); operation: "migration_verify_failed",
originalCount: originalTables.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(
operation: "migration_verify_table_failed", "Row count mismatch for table during migration verification",
table: table.name, null,
originalRows: originalCount.count, {
memoryRows: memoryCount.count, operation: "migration_verify_table_failed",
}); table: table.name,
originalRows: originalCount.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(
operation: "migration_fk_check_failed", "Foreign key constraints violations detected after migration",
violations: fkCheckResult, null,
}); {
throw new Error(`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`); operation: "migration_fk_check_failed",
violations: 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",
@@ -433,4 +380,4 @@ export class DatabaseMigration {
}); });
} }
} }
} }
+13 -39
View File
@@ -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(
operation: "db_save_trigger_force_not_init", "Database save trigger not initialized for force save",
reason, {
}); operation: "db_save_trigger_force_not_init",
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);
@@ -141,4 +115,4 @@ export class DatabaseSaveTrigger {
operation: "db_save_trigger_cleanup", operation: "db_save_trigger_cleanup",
}); });
} }
} }
+45 -32
View File
@@ -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,13 +98,11 @@ 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;
} }
} }
export { FieldCrypto, type EncryptedData }; export { FieldCrypto, type EncryptedData };
+53 -83
View File
@@ -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",
@@ -270,4 +240,4 @@ export class LazyFieldEncryption {
return { needsMigration: false, plaintextFields: [] }; return { needsMigration: false, plaintextFields: [] };
} }
} }
} }
+55 -33
View File
@@ -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;
@@ -44,27 +56,32 @@ 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) + "...";
} }
} }
return sanitized; return sanitized;
} }
@@ -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(",")}]`);
@@ -119,27 +143,25 @@ class Logger {
if (level === "debug" && process.env.NODE_ENV === "production") { if (level === "debug" && process.env.NODE_ENV === "production") {
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);
if (logInfo) { if (logInfo) {
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;
} }
} else { } else {
this.logCounts.set(logKey, { count: 1, lastLog: now }); this.logCounts.set(logKey, { count: 1, lastLog: now });
} }
return true; return true;
} }
+25 -74
View File
@@ -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,36 +131,27 @@ 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;
} }
} }
export { SimpleDBOps, type TableName }; export { SimpleDBOps, type TableName };
+8 -79
View File
@@ -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",
+27 -132
View File
@@ -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(
operation: "internal_auth_auto_generated", "Internal auth token auto-generated and saved to .env",
instanceId, {
envVarName: "INTERNAL_AUTH_TOKEN", operation: "internal_auth_auto_generated",
note: "Ready for use - no restart required" instanceId,
}); envVarName: "INTERNAL_AUTH_TOKEN",
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,85 +163,62 @@ 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;
} }
} }
} }
export { SystemCrypto }; export { SystemCrypto };
+61 -115
View File
@@ -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,26 +226,20 @@ 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 {
salt: crypto.randomBytes(32).toString("hex"), salt: crypto.randomBytes(32).toString("hex"),
@@ -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 };
+107 -76
View File
@@ -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));
: sshHosts; const processedSshHosts =
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
)
: 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)
: credentials; .where(eq(sshCredentials.userId, userId));
sshCredentialsData =
format === "plaintext" && userDataKey
? credentials.map((cred) =>
DataCrypto.decryptRecord(
"ssh_credentials",
cred,
userId,
userDataKey!,
),
)
: credentials;
} }
// Export file manager data
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([ 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,9 +267,10 @@ 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.pinned.length + data.userData.fileManagerData.recent.length +
data.userData.fileManagerData.shortcuts.length, data.userData.fileManagerData.pinned.length +
data.userData.fileManagerData.shortcuts.length,
dismissedAlerts: data.userData.dismissedAlerts.length, dismissedAlerts: data.userData.dismissedAlerts.length,
}, },
encrypted: data.metadata.encrypted, encrypted: data.metadata.encrypted,
@@ -247,4 +278,4 @@ class UserDataExport {
} }
} }
export { UserDataExport, type UserExportData }; export { UserDataExport, type UserExportData };
+92 -90
View File
@@ -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);
@@ -429,4 +431,4 @@ class UserDataImport {
} }
} }
export { UserDataImport, type ImportOptions, type ImportResult }; export { UserDataImport, type ImportOptions, type ImportResult };
+11 -5
View File
@@ -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>
); );
@@ -70,13 +72,17 @@ export function VersionAlert({
latest: updateInfo.remoteVersion, latest: updateInfo.remoteVersion,
})} })}
</div> </div>
{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>
@@ -100,7 +106,7 @@ export function VersionAlert({
{t("versionCheck.downloadUpdate")} {t("versionCheck.downloadUpdate")}
</Button> </Button>
)} )}
{showDismiss && onDismiss && ( {showDismiss && onDismiss && (
<Button <Button
variant="ghost" variant="ghost"
-1
View File
@@ -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
View File
@@ -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;
+49 -52
View File
@@ -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,44 +110,44 @@ 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;
while (currentElement && currentElement !== document.body) { while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement); const computedStyle = window.getComputedStyle(currentElement);
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;
} }
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"
@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{ {
sourcePort: 5432, sourcePort: 5432,
endpointPort: 5432, endpointPort: 5432,
endpointHost: t("interface.webServerProduction"), endpointHost: t("interface.webServerProduction"),
maxRetries: 3, maxRetries: 3,
retryInterval: 10, retryInterval: 10,
autoStart: true, autoStart: true,
+155 -157
View File
@@ -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,184 +270,183 @@ 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 ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div> <div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300"> <span className="text-gray-300">
{t("serverStats.loadingMetrics")} {t("serverStats.loadingMetrics")}
</span> </span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div> </div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div> </div>
</div> ) : !metrics && serverStatus === "offline" ? (
) : ( <div className="flex items-center justify-center py-8">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6"> <div className="text-center">
{/* CPU Stats */} <div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200"> <div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
<div className="flex items-center gap-2 mb-3"> </div>
<Cpu className="h-5 w-5 text-blue-400" /> <p className="text-gray-300 mb-1">
<h3 className="font-semibold text-lg text-white"> {t("serverStats.serverOffline")}
{t("serverStats.cpuUsage")} </p>
</h3> <p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
</div> </div>
<div className="space-y-2"> {/* Memory Stats */}
<div className="flex justify-between items-center"> <div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<span className="text-sm text-gray-300"> <div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number"
? `${used.toFixed(1)} GiB`
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => { {(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
</div>
{/* Memory Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB; const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB; const total = metrics?.memory?.totalGiB;
const pctText = const free =
typeof pct === "number" ? `${pct}%` : "N/A"; typeof used === "number" && typeof total === "number"
const usedText = ? (total - used).toFixed(1)
typeof used === "number"
? `${used.toFixed(1)} GiB`
: "N/A"; : "N/A";
const totalText = return `Free: ${free} GiB`;
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()} })()}
</span> </div>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
</div> </div>
</div> </div>
</div>
{/* Disk Stats */} {/* Disk Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200"> <div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" /> <HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")} {t("serverStats.rootStorageSpace")}
</h3> </h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => { {(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman; const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman; const total = metrics?.disk?.totalHuman;
const pctText = return used && total
typeof pct === "number" ? `${pct}%` : "N/A"; ? `Available: ${total}`
const usedText = used ?? "N/A"; : "Available: N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()} })()}
</span> </div>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</div> </div>
</div> </div>
</div> </div>
</div> )}
)}
</div> </div>
)} )}
+29 -104
View File
@@ -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) {
+2 -7
View File
@@ -27,18 +27,14 @@ 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) => {
setIsAuthenticated(true); setIsAuthenticated(true);
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);
@@ -49,8 +45,7 @@ function AppContent() {
setIsAuthenticated(false); setIsAuthenticated(false);
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"
+2 -4
View File
@@ -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");
@@ -158,7 +156,7 @@ export function Homepage({
</div> </div>
</div> </div>
)} )}
<HomepageAlertManager userId={userId} loggedIn={loggedIn} /> <HomepageAlertManager userId={userId} loggedIn={loggedIn} />
</> </>
); );
+339 -356
View File
@@ -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() {
@@ -312,12 +304,14 @@ export function HomepageAuth({
setResetSuccess(true); setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess")); toast.success(t("messages.passwordResetSuccess"));
// Immediately redirect to login after successful reset // 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);
} }
@@ -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,373 +703,364 @@ export function HomepageAuth({
</div> </div>
)} )}
{!internalLoggedIn && {!internalLoggedIn && !authLoading && !totpRequired && (
!authLoading && <>
!totpRequired && ( <div className="flex gap-2 mb-6">
<> <button
<div className="flex gap-2 mb-6"> type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
{t("common.register")}
</button>
{oidcConfigured && (
<button <button
type="button" type="button"
className={cn( className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login" tab === "external"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent", : "bg-muted text-muted-foreground hover:bg-accent",
)} )}
onClick={() => { onClick={() => {
setTab("login"); setTab("external");
if (tab === "reset") resetPasswordState(); if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields(); if (tab === "login" || tab === "signup") clearFormFields();
}} }}
aria-selected={tab === "login"} aria-selected={tab === "external"}
disabled={loading || firstUser} disabled={oidcLoading}
> >
{t("common.login")} {t("auth.external")}
</button> </button>
<button )}
type="button" </div>
className={cn( <div className="mb-6 text-center">
"flex-1 py-2 text-base font-medium rounded-md transition-all", <h2 className="text-xl font-bold mb-1">
tab === "signup" {tab === "login"
? "bg-primary text-primary-foreground shadow" ? t("auth.loginTitle")
: "bg-muted text-muted-foreground hover:bg-accent", : tab === "signup"
)} ? t("auth.registerTitle")
onClick={() => { : tab === "external"
setTab("signup"); ? t("auth.loginWithExternal")
if (tab === "reset") resetPasswordState(); : t("auth.forgotPassword")}
if (tab === "login") clearFormFields(); </h2>
}} </div>
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed} {tab === "external" || tab === "reset" ? (
> <div className="flex flex-col gap-5">
{t("common.register")} {tab === "external" && (
</button> <>
{oidcConfigured && ( <div className="text-center text-muted-foreground mb-4">
<button <p>{t("auth.loginWithExternalDesc")}</p>
type="button" </div>
className={cn( {(() => {
"flex-1 py-2 text-base font-medium rounded-md transition-all", if (isElectron()) {
tab === "external" return (
? "bg-primary text-primary-foreground shadow" <div className="text-center p-4 bg-muted/50 rounded-lg border">
: "bg-muted text-muted-foreground hover:bg-accent", <p className="text-muted-foreground text-sm">
{t("auth.externalNotSupportedInElectron")}
</p>
</div>
);
} else {
return (
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
</Button>
);
}
})()}
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)} )}
onClick={() => {
setTab("external"); {resetStep === "verify" && (
if (tab === "reset") resetPasswordState(); <>
if (tab === "login" || tab === "signup") clearFormFields(); <div className="text-center text-muted-foreground mb-4">
}} <p>
aria-selected={tab === "external"} {t("auth.enterResetCode")}{" "}
disabled={oidcLoading} <strong>{localUsername}</strong>
> </p>
{t("auth.external")} </div>
</button> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)} )}
</div> </div>
<div className="mb-6 text-center"> ) : (
<h2 className="text-xl font-bold mb-1"> <form className="flex flex-col gap-5" onSubmit={handleSubmit}>
{tab === "login" <div className="flex flex-col gap-2">
? t("auth.loginTitle") <Label htmlFor="username">{t("common.username")}</Label>
: tab === "signup" <Input
? t("auth.registerTitle") id="username"
: tab === "external" type="text"
? t("auth.loginWithExternal") required
: t("auth.forgotPassword")} className="h-11 text-base"
</h2> value={localUsername}
</div> onChange={(e) => setLocalUsername(e.target.value)}
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
</div>
{(() => {
if (isElectron()) {
return (
<div className="text-center p-4 bg-muted/50 rounded-lg border">
<p className="text-muted-foreground text-sm">
{t("auth.externalNotSupportedInElectron")}
</p>
</div>
);
} else {
return (
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
);
}
})()}
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
> />
{loading </div>
? Spinner <div className="flex flex-col gap-2">
: tab === "login" <Label htmlFor="password">{t("common.password")}</Label>
? t("common.login") <PasswordInput
: t("auth.signUp")} id="password"
</Button> required
{tab === "login" && ( className="h-11 text-base"
<Button value={password}
type="button" onChange={(e) => setPassword(e.target.value)}
variant="outline" disabled={loading || internalLoggedIn}
className="w-full h-11 text-base font-semibold" />
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
onClick={() => { />
setTab("reset"); </div>
resetPasswordState(); )}
clearFormFields(); <Button
}} type="submit"
> className="w-full h-11 mt-2 text-base font-semibold"
{t("auth.resetPasswordButton")} disabled={loading || internalLoggedIn}
</Button> >
)} {loading
</form> ? Spinner
)} : tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border space-y-4"> <div className="mt-6 pt-4 border-t border-dark-border space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
{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">
{t("common.language")} Server
</Label> </Label>
</div> <div className="text-xs text-muted-foreground truncate max-w-[200px]">
<LanguageSwitcher /> {currentServerUrl}
</div>
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
Server
</Label>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl}
</div>
</div> </div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div> </div>
)} <Button
</div> type="button"
</> variant="outline"
)} size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</>
)}
</div> </div>
); );
} }
+8 -7
View File
@@ -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();
} }
} }
+17 -41
View File
@@ -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) {
+317 -331
View File
@@ -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 {
@@ -286,12 +283,13 @@ 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,336 +521,329 @@ export function HomepageAuth({
</div> </div>
)} )}
{!internalLoggedIn && {!internalLoggedIn && !authLoading && !totpRequired && (
!authLoading && <>
!totpRequired && ( <div className="flex gap-2 mb-6">
<> <button
<div className="flex gap-2 mb-6"> type="button"
<button className={cn(
type="button" "flex-1 py-2 text-base font-medium rounded-md transition-all",
className={cn( tab === "login"
"flex-1 py-2 text-base font-medium rounded-md transition-all", ? "bg-primary text-primary-foreground shadow"
tab === "login" : "bg-muted text-muted-foreground hover:bg-accent",
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
{t("common.register")}
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)} )}
</div> onClick={() => {
<div className="mb-6 text-center"> setTab("login");
<h2 className="text-xl font-bold mb-1"> if (tab === "reset") resetPasswordState();
{tab === "login" if (tab === "signup") clearFormFields();
? t("auth.loginTitle") }}
: tab === "signup" aria-selected={tab === "login"}
? t("auth.registerTitle") disabled={loading || firstUser}
: tab === "external" >
? t("auth.loginWithExternal") {t("common.login")}
: t("auth.forgotPassword")} </button>
</h2> <button
</div> type="button"
className={cn(
{tab === "external" || tab === "reset" ? ( "flex-1 py-2 text-base font-medium rounded-md transition-all",
<div className="flex flex-col gap-5"> tab === "signup"
{tab === "external" && ( ? "bg-primary text-primary-foreground shadow"
<> : "bg-muted text-muted-foreground hover:bg-accent",
<div className="text-center text-muted-foreground mb-4"> )}
<p>{t("auth.loginWithExternalDesc")}</p> onClick={() => {
</div> setTab("signup");
<Button if (tab === "reset") resetPasswordState();
type="button" if (tab === "login") clearFormFields();
className="w-full h-11 mt-2 text-base font-semibold" }}
disabled={oidcLoading} aria-selected={tab === "signup"}
onClick={handleOIDCLogin} disabled={loading || !registrationAllowed}
> >
{oidcLoading ? Spinner : t("auth.loginWithExternal")} {t("common.register")}
</Button> </button>
</> {oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)} )}
{tab === "reset" && ( onClick={() => {
<> setTab("external");
{resetStep === "initiate" && ( if (tab === "reset") resetPasswordState();
<> if (tab === "login" || tab === "signup") clearFormFields();
<div className="text-center text-muted-foreground mb-4"> }}
<p>{t("auth.resetCodeDesc")}</p> aria-selected={tab === "external"}
</div> disabled={oidcLoading}
<div className="flex flex-col gap-4"> >
<div className="flex flex-col gap-2"> {t("auth.external")}
<Label htmlFor="reset-username"> </button>
{t("common.username")} )}
</Label> </div>
<Input <div className="mb-6 text-center">
id="reset-username" <h2 className="text-xl font-bold mb-1">
type="text" {tab === "login"
required ? t("auth.loginTitle")
className="h-11 text-base" : tab === "signup"
value={localUsername} ? t("auth.registerTitle")
onChange={(e) => setLocalUsername(e.target.value)} : tab === "external"
disabled={resetLoading} ? t("auth.loginWithExternal")
/> : t("auth.forgotPassword")}
</div> </h2>
<Button </div>
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)}
{resetStep === "verify" && ( {tab === "external" || tab === "reset" ? (
<> <div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4"> {tab === "external" && (
<p> <>
{t("auth.enterResetCode")}{" "} <div className="text-center text-muted-foreground mb-4">
<strong>{localUsername}</strong> <p>{t("auth.loginWithExternalDesc")}</p>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div> </div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button <Button
type="button" type="button"
variant="outline" className="w-full h-11 mt-2 text-base font-semibold"
className="w-full h-11 text-base font-semibold" disabled={oidcLoading}
disabled={loading || internalLoggedIn} onClick={handleOIDCLogin}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
> >
{t("auth.resetPasswordButton")} {oidcLoading ? Spinner : t("auth.loginWithExternal")}
</Button> </Button>
)} </>
</form> )}
)} {tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)}
<div className="mt-6 pt-4 border-t border-dark-border"> {resetStep === "verify" && (
<div className="flex items-center justify-between"> <>
<div> <div className="text-center text-muted-foreground mb-4">
<Label className="text-sm text-muted-foreground"> <p>
{t("common.language")} {t("auth.enterResetCode")}{" "}
</Label> <strong>{localUsername}</strong>
</div> </p>
<LanguageSwitcher /> </div>
</div> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div> </div>
</> ) : (
)} <form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
</div>
</>
)}
</div> </div>
); );
} }
+2 -7
View File
@@ -25,18 +25,14 @@ 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) => {
setIsAuthenticated(true); setIsAuthenticated(true);
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);
@@ -47,8 +43,7 @@ const AppContent: FC = () => {
setIsAuthenticated(false); setIsAuthenticated(false);
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");
+1 -1
View File
@@ -60,7 +60,7 @@ async function handleLogout() {
try { try {
// Call backend logout endpoint to clear HttpOnly cookie and data session // Call backend logout endpoint to clear HttpOnly cookie and data session
await logoutUser(); await logoutUser();
// Reload the page to reset the application state // Reload the page to reset the application state
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
+3 -9
View File
@@ -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" />
)} )}
+14 -23
View File
@@ -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],
+18 -41
View File
@@ -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,
}; };
} }
+33 -46
View File
@@ -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,33 +268,26 @@ 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");
import("sonner").then(({ toast }) => { import("sonner").then(({ toast }) => {
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(
sessionId, "/ssh/moveItem",
oldPath, {
newPath, sessionId,
hostId, oldPath,
userId, newPath,
}, { hostId,
timeout: 60000, // 60 second timeout for move operations userId,
}); },
{
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,
+6 -4
View File
@@ -22,10 +22,12 @@ export default defineConfig({
}, },
base: "./", base: "./",
server: { server: {
https: useHTTPS ? { https: useHTTPS
cert: fs.readFileSync(sslCertPath), ? {
key: fs.readFileSync(sslKeyPath), cert: fs.readFileSync(sslCertPath),
} : false, key: fs.readFileSync(sslKeyPath),
}
: false,
port: 5173, port: 5173,
host: "localhost", host: "localhost",
}, },