Code cleanup

This commit is contained in:
LukeGus
2025-09-28 00:23:00 -05:00
parent d2ba934f61
commit bc8aa69099
76 changed files with 62289 additions and 6806 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,125 +19,51 @@ if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Database file encryption configuration
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
const dbPath = path.join(dataDir, "db.sqlite");
const encryptedDbPath = `${dbPath}.encrypted`;
// Initialize database with file encryption support
let actualDbPath = ":memory:"; // Always use memory database
let actualDbPath = ":memory:";
let memoryDatabase: Database.Database;
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> {
// 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();
// 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) {
try {
// Check if encrypted database exists
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
databaseLogger.info(
"Found encrypted database file, loading into memory...",
{
operation: "db_memory_load",
encryptedPath: encryptedDbPath,
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 =
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);
databaseLogger.info("In-memory database created from decrypted buffer", {
operation: "db_memory_create_success",
});
} else {
// No encrypted database exists - check if we need to migrate
const migration = new DatabaseMigration(dataDir);
const migrationStatus = migration.checkMigrationStatus();
databaseLogger.info("Migration status check completed", {
operation: "migration_status",
needsMigration: migrationStatus.needsMigration,
hasUnencryptedDb: migrationStatus.hasUnencryptedDb,
hasEncryptedDb: migrationStatus.hasEncryptedDb,
unencryptedDbSize: migrationStatus.unencryptedDbSize,
reason: migrationStatus.reason,
});
if (migrationStatus.needsMigration) {
// Perform automatic migration
databaseLogger.info("Starting automatic database migration", {
operation: "auto_migration_start",
unencryptedDbSize: migrationStatus.unencryptedDbSize,
});
const migrationResult = await migration.migrateDatabase();
if (migrationResult.success) {
databaseLogger.success("Automatic database migration completed successfully", {
operation: "auto_migration_success",
migratedTables: migrationResult.migratedTables,
migratedRows: migrationResult.migratedRows,
duration: migrationResult.duration,
backupPath: migrationResult.backupPath,
});
// Clean up old backup files
migration.cleanupOldBackups();
// Load the newly created encrypted database
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
databaseLogger.info("Loading migrated encrypted database into memory", {
operation: "load_migrated_db",
encryptedPath: encryptedDbPath,
});
const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
if (
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
) {
const decryptedBuffer =
await DatabaseFileEncryption.decryptDatabaseToBuffer(
encryptedDbPath,
);
memoryDatabase = new Database(decryptedBuffer);
isNewDatabase = false; // We have migrated data
databaseLogger.success("Migrated encrypted database loaded successfully", {
operation: "load_migrated_db_success",
decryptedSize: decryptedBuffer.length,
});
isNewDatabase = false;
} else {
throw new Error("Migration completed but encrypted database file not found");
throw new Error(
"Migration completed but encrypted database file not found",
);
}
} else {
// Migration failed - this is critical
databaseLogger.error("Automatic database migration failed", null, {
operation: "auto_migration_failed",
error: migrationResult.error,
@@ -146,24 +72,13 @@ async function initializeDatabaseAsync(): Promise<void> {
duration: migrationResult.duration,
backupPath: migrationResult.backupPath,
});
// CRITICAL: Migration failure with existing data
console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
console.error("Migration error:", migrationResult.error);
console.error("Backup available at:", migrationResult.backupPath);
console.error("Manual intervention required to recover data.");
throw new Error(`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`);
throw new Error(
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
);
}
} else {
// No migration needed - create fresh database
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
databaseLogger.info("Creating fresh in-memory database", {
operation: "fresh_db_create",
reason: migrationStatus.reason,
});
}
}
} catch (error) {
@@ -171,20 +86,15 @@ async function initializeDatabaseAsync(): Promise<void> {
operation: "db_memory_init_failed",
errorMessage: error instanceof Error ? error.message : "Unknown error",
errorStack: error instanceof Error ? error.stack : undefined,
encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
encryptedDbExists:
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
databaseKeyAvailable: !!process.env.DATABASE_KEY,
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
});
// CRITICAL: Never silently ignore database decryption failures!
// This causes complete data loss for users
console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
console.error("Error details:", error instanceof Error ? error.message : error);
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
// Always fail fast on decryption errors - data integrity is critical
throw new Error(`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`);
throw new Error(
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
);
}
} else {
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> {
// First initialize the database and SystemCrypto
await initializeDatabaseAsync();
databaseLogger.info(`Initializing SQLite database`, {
@@ -207,17 +115,10 @@ async function initializeCompleteDatabase(): Promise<void> {
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,
@@ -340,19 +241,13 @@ async function initializeCompleteDatabase(): Promise<void> {
`);
// 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')",
@@ -383,11 +278,6 @@ const addColumnIfNotExists = (
try {
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",
@@ -400,10 +290,6 @@ const addColumnIfNotExists = (
};
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");
@@ -469,13 +355,10 @@ const migrateSchema = () => {
"INTEGER REFERENCES ssh_credentials(id)",
);
// AutoStart plaintext columns
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
// SSH credentials table migrations for encryption support
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "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() {
if (!memoryDatabase) return;
try {
// Export in-memory database to buffer
const buffer = memoryDatabase.serialize();
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
databaseLogger.info("Created data directory", {
operation: "data_dir_create",
path: dataDir,
});
}
if (enableFileEncryption) {
// Save as encrypted file
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
encryptedDbPath,
);
} else {
// Fallback: save as unencrypted SQLite file to prevent data loss
fs.writeFileSync(dbPath, buffer);
}
} catch (error) {
@@ -521,55 +398,30 @@ async function saveMemoryDatabaseToFile() {
}
}
// Function to handle post-initialization file encryption and periodic saves
async function handlePostInitFileEncryption() {
if (!enableFileEncryption) return;
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) {
// Save immediately after initialization
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);
// Initialize database save trigger for real-time saves
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
}
// Perform migration cleanup on startup (remove old backup files)
try {
const migration = new DatabaseMigration(dataDir);
migration.cleanupOldBackups();
} catch (cleanupError) {
databaseLogger.warn("Failed to cleanup old migration files", {
operation: "migration_cleanup_startup_failed",
error: cleanupError instanceof Error ? cleanupError.message : "Unknown error",
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
});
}
} catch (error) {
databaseLogger.error(
"Failed to handle database file encryption setup",
@@ -578,31 +430,17 @@ async function handlePostInitFileEncryption() {
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> {
await initializeCompleteDatabase();
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 };
// Cleanup function for database and temporary files
async function cleanupDatabase() {
// Save in-memory database before closing
if (memoryDatabase) {
try {
await saveMemoryDatabaseToFile();
@@ -617,7 +455,6 @@ async function cleanupDatabase() {
}
}
// Close database connection
try {
if (sqlite) {
sqlite.close();
@@ -629,7 +466,6 @@ async function cleanupDatabase() {
});
}
// Clean up temp directory
try {
const tempDir = path.join(dataDir, ".temp");
if (fs.existsSync(tempDir)) {
@@ -637,25 +473,17 @@ async function cleanupDatabase() {
for (const file of files) {
try {
fs.unlinkSync(path.join(tempDir, file));
} catch {
// Ignore individual file cleanup errors
}
} catch {}
}
try {
fs.rmdirSync(tempDir);
} catch {
// Ignore directory removal errors
}
} catch {}
}
} catch (error) {
// Ignore temp directory cleanup errors
}
} catch (error) {}
}
// Register cleanup handlers
process.on("exit", () => {
// Synchronous cleanup only for exit event
if (sqlite) {
try {
sqlite.close();
@@ -679,26 +507,26 @@ process.on("SIGTERM", async () => {
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 initializeDatabase() is called before accessing db.");
throw new Error(
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
);
}
return db;
}
// Export raw SQLite instance for migrations
export function getSqlite(): Database.Database {
if (!sqlite) {
throw new Error("SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.");
throw new Error(
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
);
}
return sqlite;
}
// Legacy export for compatibility - will throw if accessed before initialization
export { db };
export { DatabaseFileEncryption };
export const databasePaths = {
@@ -708,30 +536,6 @@ export const databasePaths = {
inMemory: true,
};
// Memory database buffer function
function getMemoryDatabaseBuffer(): Buffer {
if (!memoryDatabase) {
throw new Error("Memory database not initialized");
}
export { saveMemoryDatabaseToFile };
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 };

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 };

View File

@@ -49,7 +49,6 @@ export const sshData = sqliteTable("ssh_data", {
keyPassword: text("key_password"),
keyType: text("key_type"),
// AutoStart plaintext fields (populated only when autoStart is enabled)
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key", { length: 8192 }),
autostartKeyPassword: text("autostart_key_password"),
@@ -142,7 +141,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
authType: text("auth_type").notNull(),
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }), // backward compatibility
key: text("key", { length: 16384 }),
privateKey: text("private_key", { length: 16384 }),
publicKey: text("public_key", { length: 4096 }),
keyPassword: text("key_password"),
@@ -173,4 +172,3 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -108,7 +108,6 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const router = express.Router();
// Initialize auth middleware
const authManager = AuthManager.getInstance();
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
// POST /alerts/dismiss
router.post("/dismiss", authenticateJWT, async (req, res) => {

File diff suppressed because it is too large Load Diff

View File

@@ -23,10 +23,6 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
interface JWTPayload {
userId: string;
}
function isNonEmptyString(value: any): value is string {
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;
}
// Use AuthManager middleware for authentication
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Internal-only endpoint for autostart - requires internal auth token
router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
// Check for internal authentication token using SystemCrypto
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint", {
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing"
});
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
@@ -63,25 +58,16 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
}
try {
// Query sshData directly for hosts that have autostart plaintext fields populated
const autostartHosts = await db.select()
const autostartHosts = await db
.select()
.from(sshData)
.where(
// Check if any autostart fields are populated (meaning autostart is enabled)
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey)
)
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 tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
@@ -97,13 +83,14 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
// Include explicit autostart fields for tunnel service
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
tunnelConnections: tunnelConnections.filter(
(tunnel: any) => tunnel.autoStart,
),
pin: false,
enableTerminal: 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) => {
try {
// Check for internal authentication token using SystemCrypto
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res.status(401).json({ error: "Internal authentication token required" });
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res.status(401).json({ error: "Invalid internal authentication token" });
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
// Query all hosts for endpointHost resolution
const allHosts = await db.select().from(sshData);
sshLogger.info("Internal all hosts endpoint accessed", {
operation: "all_hosts_internal_access",
hostCount: allHosts.length,
source: req.ip,
userAgent: req.headers["user-agent"]
});
// Transform to expected format for tunnel service
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
@@ -160,7 +140,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
keyPassword: host.autostartKeyPassword || host.keyPassword,
// Include autostart fields for fallback
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -291,7 +270,6 @@ router.post(
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
// For credential auth
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
@@ -1381,103 +1359,116 @@ router.post(
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn("Missing or invalid sshConfigId in autostart enable request", {
operation: "autostart_enable",
userId,
sshConfigId
});
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
// Validate user has access to decrypt the data
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn("User attempted to enable autostart without unlocked data", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked"
});
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked."
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
// Get and decrypt SSH configuration
const sshConfig = await db.select()
const sshConfig = await db
.select()
.from(sshData)
.where(and(
eq(sshData.id, sshConfigId),
eq(sshData.userId, userId)
));
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found"
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found"
error: "SSH configuration not found",
});
}
const config = sshConfig[0];
// Decrypt sensitive fields
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
// Also handle tunnel connections - populate endpoint credentials
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
// For each tunnel connection, try to resolve endpoint credentials
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: any) => {
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
// Find endpoint host by name or username@ip
const endpointHosts = await db.select()
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHosts = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const endpointHost = endpointHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
// Decrypt endpoint host credentials
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
// Add endpoint credentials to tunnel connection
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
})
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
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.update(sshData)
const updateResult = await db
.update(sshData)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
@@ -1486,36 +1477,29 @@ router.post(
})
.where(eq(sshData.id, sshConfigId));
// Force database save after autostart update
try {
await DatabaseSaveTrigger.triggerSave();
} catch (saveError) {
sshLogger.warn("Database save failed after autostart", {
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({
message: "AutoStart enabled successfully",
sshConfigId
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
}
},
);
// Route: Disable autostart for SSH configuration (requires JWT)
@@ -1528,46 +1512,40 @@ router.delete(
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn("Missing or invalid sshConfigId in autostart disable request", {
operation: "autostart_disable",
userId,
sshConfigId
});
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
// Clear the autostart plaintext fields for this SSH config
const result = await db.update(sshData)
const result = await db
.update(sshData)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(
eq(sshData.id, sshConfigId),
eq(sshData.userId, userId)
));
sshLogger.info("AutoStart disabled successfully", {
operation: "autostart_disabled",
userId,
sshConfigId
});
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
res.json({
message: "AutoStart disabled successfully",
sshConfigId
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
}
},
);
// Route: Get autostart status for user's SSH configurations (requires JWT)
@@ -1579,44 +1557,39 @@ router.get(
const userId = (req as any).userId;
try {
// Query user's SSH configs that have autostart enabled
const autostartConfigs = await db.select()
const autostartConfigs = await db
.select()
.from(sshData)
.where(and(
eq(sshData.userId, userId),
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey)
)
));
.where(
and(
eq(sshData.userId, userId),
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey),
),
),
);
// Map to just the basic info needed for status
const statusList = autostartConfigs.map(config => ({
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType
authType: config.authType,
}));
sshLogger.info("AutoStart status retrieved", {
operation: "autostart_status",
userId,
configCount: statusList.length
});
res.json({
autostart_configs: statusList,
total_count: statusList.length
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId
userId,
});
res.status(500).json({ error: "Internal server error" });
}
}
},
);
export default router;

View File

@@ -13,16 +13,13 @@ import {
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import jwt from "jsonwebtoken";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import type { Request, Response, NextFunction } from "express";
import { authLogger, apiLogger } from "../../utils/logger.js";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { UserCrypto } from "../../utils/user-crypto.js";
import { DataCrypto } from "../../utils/data-crypto.js";
// Get auth manager instance
const authManager = AuthManager.getInstance();
async function verifyOIDCToken(
@@ -137,11 +134,8 @@ interface JWTPayload {
exp?: number;
}
// JWT authentication middleware - only verify JWT, no data unlock required
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
// Data access middleware - requires user to have unlocked data keys
const requireDataAccess = authManager.createDataAccessMiddleware();
// Route: Create traditional user (username/password)
@@ -220,22 +214,20 @@ router.post("/create", async (req, res) => {
totp_backup_codes: null,
});
// Set up user data encryption (KEK-DEK architecture)
try {
await authManager.registerUser(id, password);
authLogger.success("User encryption setup completed", {
operation: "user_encryption_setup",
userId: id,
});
} catch (encryptionError) {
// If encryption setup fails, delete user record
await db.delete(users).where(eq(users.id, id));
authLogger.error("Failed to setup user encryption, user creation rolled back", encryptionError, {
operation: "user_create_encryption_failed",
userId: id,
});
authLogger.error(
"Failed to setup user encryption, user creation rolled back",
encryptionError,
{
operation: "user_create_encryption_failed",
userId: id,
},
);
return res.status(500).json({
error: "Failed to setup user security - user creation cancelled"
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",
};
// Encrypt sensitive configuration for storage
let encryptedConfig;
try {
// Use admin's data key to encrypt OIDC configuration
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
// Provide stable recordId for settings objects
const configWithId = { ...config, id: `oidc-config-${userId}` };
encryptedConfig = DataCrypto.encryptRecord("settings", configWithId, userId, adminDataKey);
encryptedConfig = DataCrypto.encryptRecord(
"settings",
configWithId,
userId,
adminDataKey,
);
authLogger.info("OIDC configuration encrypted with admin data key", {
operation: "oidc_config_encrypt",
userId,
});
} else {
// If admin data not unlocked, only encrypt client_secret
encryptedConfig = {
...config,
client_secret: `encrypted:${Buffer.from(client_secret).toString('base64')}`, // Simple base64 encoding
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
};
authLogger.warn("OIDC configuration stored with basic encoding - admin should re-save with password", {
operation: "oidc_config_basic_encoding",
userId,
});
authLogger.warn(
"OIDC configuration stored with basic encoding - admin should re-save with password",
{
operation: "oidc_config_basic_encoding",
userId,
},
);
}
} catch (encryptError) {
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, {
operation: "oidc_config_encrypt_failed",
userId,
});
authLogger.error(
"Failed to encrypt OIDC configuration, storing with basic encoding",
encryptError,
{
operation: "oidc_config_encrypt_failed",
userId,
},
);
encryptedConfig = {
...config,
client_secret: `encoded:${Buffer.from(client_secret).toString('base64')}`,
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);
// Decrypt or decode client_secret for display
if (config.client_secret) {
if (config.client_secret.startsWith('encrypted:')) {
// Requires admin permission to decrypt
if (config.client_secret.startsWith("encrypted:")) {
const authHeader = req.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
@@ -438,16 +436,22 @@ router.get("/oidc-config", async (req, res) => {
if (payload) {
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) {
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
// Use same stable recordId for decryption - note: FieldCrypto will use stored recordId
config = DataCrypto.decryptRecord("settings", config, userId, adminDataKey);
config = DataCrypto.decryptRecord(
"settings",
config,
userId,
adminDataKey,
);
} else {
// Admin data not unlocked, hide client_secret
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
}
} catch (decryptError) {
@@ -466,16 +470,17 @@ router.get("/oidc-config", async (req, res) => {
} else {
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
}
} else if (config.client_secret.startsWith('encoded:')) {
// base64 decode
} else if (config.client_secret.startsWith("encoded:")) {
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;
} catch {
config.client_secret = "[ENCODING ERROR]";
}
}
// Otherwise plaintext, return directly
}
res.json(config);
@@ -788,7 +793,11 @@ router.get("/oidc/callback", async (req, res) => {
redirectUrl.searchParams.set("success", "true");
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());
} catch (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" });
}
// Check if legacy user needs encryption setup
try {
const kekSalt = await db
.select()
@@ -865,13 +873,7 @@ router.post("/login", async (req, res) => {
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
if (kekSalt.length === 0) {
// Legacy user first login - set up new encryption
await authManager.registerUser(userRecord.id, password);
authLogger.success("Legacy user encryption initialized", {
operation: "legacy_user_setup",
username,
userId: userRecord.id,
});
}
} catch (setupError) {
authLogger.error("Failed to initialize user encryption", setupError, {
@@ -879,11 +881,12 @@ router.post("/login", async (req, res) => {
username,
userId: userRecord.id,
});
// Encryption setup failure should not block login for existing users
}
// Unlock user data keys
const dataUnlocked = await authManager.authenticateUser(userRecord.id, password);
const dataUnlocked = await authManager.authenticateUser(
userRecord.id,
password,
);
if (!dataUnlocked) {
authLogger.error("Failed to unlock user data during login", undefined, {
operation: "user_login_data_unlock_failed",
@@ -891,11 +894,10 @@ router.post("/login", async (req, res) => {
userId: userRecord.id,
});
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) {
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
@@ -907,7 +909,6 @@ router.post("/login", async (req, res) => {
});
}
// Generate normal JWT token
const token = await authManager.generateJWTToken(userRecord.id, {
expiresIn: "24h",
});
@@ -920,7 +921,11 @@ router.post("/login", async (req, res) => {
});
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
)
.json({
success: true,
is_admin: !!userRecord.is_admin,
@@ -936,19 +941,16 @@ router.post("/login", async (req, res) => {
// POST /users/logout
router.post("/logout", async (req, res) => {
try {
// Try to get userId from JWT if available
const userId = (req as any).userId;
if (userId) {
// User is authenticated - clear data session
authManager.logoutUser(userId);
authLogger.info("User logged out", {
operation: "user_logout",
userId,
});
}
// Always clear the JWT cookie
return res
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
.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" });
}
// Check if user data is unlocked
const isDataUnlocked = authManager.isUserUnlocked(userId);
res.json({
userId: user[0].id,
username: user[0].username,
@@ -1001,7 +1002,6 @@ router.get("/setup-required", async (req, res) => {
res.json({
setup_required: count === 0,
// 不暴露具体用户数量,只返回是否需要初始化
});
} catch (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) => {
const userId = (req as any).userId;
try {
// 只有管理员可以查看用户统计
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user[0] || !user[0].is_admin) {
return res.status(403).json({ error: "Admin access required" });
@@ -1282,14 +1281,15 @@ router.post("/complete-reset", async (req, res) => {
return res.status(400).json({ error: "Invalid temporary token" });
}
// Get user ID for KEK-DEK operations
const user = await db.select().from(users).where(eq(users.username, username));
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userId = user[0].id;
// Update password hash
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
@@ -1490,7 +1490,11 @@ router.post("/totp/verify-login", async (req, 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({
success: true,
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
// POST /users/unlock-data
router.post("/unlock-data", authenticateJWT, async (req, res) => {
@@ -1817,13 +1819,9 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
try {
const unlocked = await authManager.authenticateUser(userId, password);
if (unlocked) {
authLogger.success("User data unlocked", {
operation: "user_data_unlock",
userId,
});
res.json({
success: true,
message: "Data unlocked successfully"
message: "Data unlocked successfully",
});
} else {
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
router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const isUnlocked = authManager.isUserUnlocked(userId);
res.json({
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) {
authLogger.error("Failed to check data status", err, {
@@ -1869,26 +1869,24 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
if (!currentPassword || !newPassword) {
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) {
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 {
// Verify current password and change
const success = await authManager.changeUserPassword(
userId,
currentPassword,
newPassword
newPassword,
);
if (success) {
// Also update password hash in database
const saltRounds = parseInt(process.env.SALT || "10", 10);
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
await db
@@ -1903,7 +1901,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
res.json({
success: true,
message: "Password changed successfully"
message: "Password changed successfully",
});
} else {
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;

View File

@@ -9,13 +9,10 @@ import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
// Executable file detection utility function
function isExecutableFile(permissions: string, fileName: string): boolean {
// Check execute permission bits (user, group, other)
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
// Common script file extensions
const scriptExtensions = [
".sh",
".py",
@@ -31,13 +28,11 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
fileName.toLowerCase().endsWith(ext),
);
// Common compiled executable files (no extension or specific extensions)
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
// Files with no extension and execute permission are usually executable files
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return (
@@ -51,33 +46,27 @@ const app = express();
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:3000",
"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://")) {
return callback(null, true);
}
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) {
return callback(null, true);
}
// Check against allowed development origins
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Reject other origins
callback(new Error("Not allowed by CORS"));
},
credentials: true,
@@ -95,7 +84,6 @@ app.use(express.json({ limit: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
// Initialize AuthManager and add authentication middleware
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
@@ -122,16 +110,37 @@ function cleanupSession(sessionId: string) {
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
// Clear existing timeout
if (session.timeout) clearTimeout(session.timeout);
// Increase timeout to 30 minutes of inactivity
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
session.timeout = setTimeout(
() => {
cleanupSession(sessionId);
},
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) => {
const {
sessionId,
@@ -146,7 +155,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
credentialId,
} = req.body;
// Use authenticated user ID from middleware
const userId = (req as any).userId;
if (!userId) {
@@ -194,7 +202,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
authType: credential.authType,
};
@@ -255,7 +263,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
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"],
},
};
@@ -352,7 +367,6 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
res.json({ status: "success", connected: isConnected });
});
// SSH keepalive endpoint - extends session timeout and verifies connection
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
const { sessionId } = req.body;
@@ -365,11 +379,10 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
connected: false
connected: false,
});
}
// Update last active time and reschedule cleanup
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
@@ -377,7 +390,7 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
status: "success",
connected: true,
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 size = parseInt(parts[4], 10);
// Date may occupy 3 parts (month day time) or (month day year)
let dateStr = "";
let nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
// Regular format: month day time/year
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
@@ -450,7 +461,6 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
if (name === "." || name === "..") continue;
// Parse symbolic link target
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
@@ -462,17 +472,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size, // Directories don't show size
size: isDirectory ? undefined : size,
modified: dateStr,
permissions,
owner,
group,
linkTarget, // Symbolic link target
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // Add full path
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? 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();
// Support large file reading - increased limit for better compatibility
const MAX_READ_SIZE = 500 * 1024 * 1024; // 500MB - much more reasonable limit
const MAX_READ_SIZE = 500 * 1024 * 1024;
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
// Get file size first
sshConn.client.exec(
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
(sizeErr, sizeStream) => {
@@ -594,20 +602,18 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
sizeStream.on("close", (sizeCode) => {
if (sizeCode !== 0) {
// Check if it's a file not found error (case-insensitive)
const errorLower = sizeErrorData.toLowerCase();
const isFileNotFound = errorLower.includes("no such file or directory") ||
errorLower.includes("cannot access") ||
errorLower.includes("not found") ||
errorLower.includes("resource not found");
const isFileNotFound =
errorLower.includes("no such file or directory") ||
errorLower.includes("cannot access") ||
errorLower.includes("not found") ||
errorLower.includes("resource not found");
fileLogger.error(`File size check failed: ${sizeErrorData}`);
return res
.status(isFileNotFound ? 404 : 500)
.json({
error: `Cannot check file size: ${sizeErrorData}`,
fileNotFound: isFileNotFound
});
return res.status(isFileNotFound ? 404 : 500).json({
error: `Cannot check file size: ${sizeErrorData}`,
fileNotFound: isFileNotFound,
});
}
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" });
}
// Check if file is too large
if (fileSize > MAX_READ_SIZE) {
fileLogger.warn("File too large for reading", {
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) => {
if (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()}`,
);
// Check if it's a "file not found" error
const isFileNotFound =
errorData.includes("No such file or directory") ||
errorData.includes("cannot access") ||
errorData.includes("not found");
return res
.status(isFileNotFound ? 404 : 500)
.json({
error: `Command failed: ${errorData}`,
fileNotFound: isFileNotFound
});
return res.status(isFileNotFound ? 404 : 500).json({
error: `Command failed: ${errorData}`,
fileNotFound: isFileNotFound,
});
}
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" });
}
// Update last active time and extend keepalive for large file operations
sshConn.lastActive = Date.now();
// For large files, extend the keepalive interval to prevent connection drops
const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length;
if (contentSize > 10 * 1024 * 1024) { // 10MB threshold
fileLogger.info("Large file upload detected, extending SSH keepalive", {
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 contentSize =
typeof content === "string"
? Buffer.byteLength(content, "utf8")
: content.length;
const fullPath = filePath.endsWith("/")
? filePath + fileName
@@ -958,7 +950,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
fileName,
fileSize: contentSize,
error: streamErr.message,
}
},
);
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) => {
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
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`;
// Add timeout for move operation
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
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) => {
if (err) {
@@ -1745,14 +1735,12 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Use SFTP to read file for binary safety
sshConn.client.sftp((err, sftp) => {
if (err) {
fileLogger.error("SFTP connection failed for download:", err);
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) => {
if (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" });
}
// Support large file downloads - increased limit for better compatibility
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - reasonable for SSH file operations
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
if (stats.size > MAX_FILE_SIZE) {
fileLogger.warn("File too large for 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) => {
if (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}` });
}
// Convert to base64 for safe transport
const base64Content = data.toString("base64");
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) => {
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();
scheduleSessionCleanup(sessionId);
try {
// Extract source name
const sourceName = sourcePath.split("/").pop() || "copied_item";
const sourceName = sourcePath.split("/").pop() || "copied_item";
// Linus principle: simplify - generate unique name directly without complex checks
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
fileLogger.info("Starting copy operation", {
originalName: sourceName,
uniqueName,
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath,
targetPath,
sessionId,
command: copyCommand,
});
// Escape paths for shell commands
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
// Linus principle: simplify - use basic cp command for reliability
// Just copy the file without complex flags that might cause issues
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
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,
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message: "Copy operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
sshConn.client.exec(copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
return res.status(500).json({ error: err.message });
}
}, 60000); // 60 second timeout for large files
return;
}
sshConn.client.exec(copyCommand, (err, stream) => {
if (err) {
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),
});
});
let errorData = "";
let stdoutData = "";
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
stream.stderr.on("data", (data: Buffer) => {
const output = data.toString();
errorData += output;
fileLogger.info("Copy command stderr", {
output: output.substring(0, 200),
});
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
fileLogger.info("Copy command completed", {
code,
errorData,
hasError: errorData.length > 0,
});
if (code !== 0) {
const fullErrorInfo =
@@ -1965,8 +1910,8 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
return;
}
// Verify copy completion with COPY_SUCCESS marker or exit code 0
const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0;
const copySuccessful =
stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
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
function getMimeType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: "text/plain",
json: "application/json",
js: "text/javascript",
html: "text/html",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath, hostId, userId } = req.body;
const sshConn = sshSessions[sessionId];
// Execute executable file
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath, hostId, userId } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
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" });
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
checkStream.on("close", (code) => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
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
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
fileLogger.info("Executing file", {
operation: "execute_file",
sessionId,
filePath,
command: executeCommand.substring(0, 100) + "...",
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
sshConn.client.exec(executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
checkStream.on("close", (code) => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}
let output = "";
let errorOutput = "";
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
stream.on("data", (data) => {
output += data.toString();
});
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" });
sshConn.client.exec(executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
}
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;
app.listen(PORT, async () => {
fileLogger.success("File Manager API server started", {
operation: "server_start",
port: PORT,
const PORT = 30004;
app.listen(PORT, async () => {
try {
await authManager.initialize();
} 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",
});
}
});

View File

@@ -282,30 +282,30 @@ app.use(
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:3000",
"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://")) {
return callback(null, true);
}
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) {
return callback(null, true);
}
// Check against allowed development origins
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Reject other origins
callback(new Error("Not allowed by CORS"));
},
@@ -327,7 +327,9 @@ app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
async function fetchAllHosts(
userId: string,
): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
@@ -366,13 +368,16 @@ async function fetchHostById(
statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked",
userId,
hostId: id
hostId: id,
});
return undefined;
}
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",
userId,
);
@@ -512,7 +517,14 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
} as ConnectConfig;
@@ -879,7 +891,7 @@ app.get("/status", async (req, res) => {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
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)) {
return res.status(401).json({
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)) {
return res.status(401).json({
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)) {
return res.status(401).json({
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", () => {
statsLogger.info("Received SIGINT, shutting down gracefully");
connectionPool.destroy();
process.exit(0);
});
process.on("SIGTERM", () => {
statsLogger.info("Received SIGTERM, shutting down gracefully");
connectionPool.destroy();
process.exit(0);
});
const PORT = 30005;
app.listen(PORT, async () => {
statsLogger.success("Server Stats API server started", {
operation: "server_start",
port: PORT,
});
// Initialize AuthManager for JWT verification
try {
await authManager.initialize();
statsLogger.info("AuthManager initialized for metrics collection", {
operation: "auth_init",
});
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
// Skip initial poll - requires user authentication
statsLogger.info("Server ready - status polling will begin with first authenticated request", {
operation: "server_ready",
});
});

View File

@@ -9,16 +9,13 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
// Get auth instances
const authManager = AuthManager.getInstance();
const userCrypto = UserCrypto.getInstance();
// Track user connections for rate limiting
const userConnections = new Map<string, Set<WebSocket>>();
const wss = new WebSocketServer({
port: 30002,
// WebSocket authentication during handshake
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url!, true);
@@ -28,7 +25,7 @@ const wss = new WebSocketServer({
sshLogger.warn("WebSocket connection rejected: missing token", {
operation: "websocket_auth_reject",
reason: "missing_token",
ip: info.req.socket.remoteAddress
ip: info.req.socket.remoteAddress,
});
return false;
}
@@ -39,23 +36,24 @@ const wss = new WebSocketServer({
sshLogger.warn("WebSocket connection rejected: invalid token", {
operation: "websocket_auth_reject",
reason: "invalid_token",
ip: info.req.socket.remoteAddress
ip: info.req.socket.remoteAddress,
});
return false;
}
// Check for TOTP pending (should not allow terminal access during TOTP)
if (payload.pendingTOTP) {
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
operation: "websocket_auth_reject",
reason: "totp_pending",
userId: payload.userId,
ip: info.req.socket.remoteAddress
});
sshLogger.warn(
"WebSocket connection rejected: TOTP verification pending",
{
operation: "websocket_auth_reject",
reason: "totp_pending",
userId: payload.userId,
ip: info.req.socket.remoteAddress,
},
);
return false;
}
// Check connection limits per user (max 3 concurrent connections)
const existingConnections = userConnections.get(payload.userId);
if (existingConnections && existingConnections.size >= 3) {
sshLogger.warn("WebSocket connection rejected: too many connections", {
@@ -63,39 +61,29 @@ const wss = new WebSocketServer({
reason: "connection_limit",
userId: payload.userId,
currentConnections: existingConnections.size,
ip: info.req.socket.remoteAddress
ip: info.req.socket.remoteAddress,
});
return false;
}
// Note: We don't need to attach user info to request anymore
// Connection handler will re-verify JWT directly from URL
sshLogger.info("WebSocket connection authenticated", {
operation: "websocket_auth_success",
userId: payload.userId,
ip: info.req.socket.remoteAddress
});
return true;
} catch (error) {
sshLogger.error("WebSocket authentication error", error, {
operation: "websocket_auth_error",
ip: info.req.socket.remoteAddress
ip: info.req.socket.remoteAddress,
});
return false;
}
}
},
});
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
operation: "server_start",
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) => {
// Linus principle: eliminate complexity - always parse JWT from URL directly
let userId: string | undefined;
let userPayload: any;
@@ -104,75 +92,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
const token = url.query.token as string;
if (!token) {
sshLogger.warn("WebSocket connection rejected: missing token in connection", {
operation: "websocket_connection_reject",
reason: "missing_token",
ip: req.socket.remoteAddress
});
sshLogger.warn(
"WebSocket connection rejected: missing token in connection",
{
operation: "websocket_connection_reject",
reason: "missing_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload) {
sshLogger.warn("WebSocket connection rejected: invalid token in connection", {
operation: "websocket_connection_reject",
reason: "invalid_token",
ip: req.socket.remoteAddress
});
sshLogger.warn(
"WebSocket connection rejected: invalid token in connection",
{
operation: "websocket_connection_reject",
reason: "invalid_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
userPayload = payload;
} catch (error) {
sshLogger.error("WebSocket JWT verification failed during connection", error, {
operation: "websocket_connection_auth_error",
ip: req.socket.remoteAddress
});
sshLogger.error(
"WebSocket JWT verification failed during connection",
error,
{
operation: "websocket_connection_auth_error",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
// Check data access permissions
const dataKey = userCrypto.getUserDataKey(userId);
if (!dataKey) {
sshLogger.warn("WebSocket connection rejected: data locked", {
operation: "websocket_data_locked",
userId,
ip: req.socket.remoteAddress
ip: req.socket.remoteAddress,
});
ws.send(JSON.stringify({
type: "error",
message: "Data locked - re-authenticate with password",
code: "DATA_LOCKED"
}));
ws.send(
JSON.stringify({
type: "error",
message: "Data locked - re-authenticate with password",
code: "DATA_LOCKED",
}),
);
ws.close(1008, "Data access required");
return;
}
// Track user connections for limits
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
const userWs = userConnections.get(userId)!;
userWs.add(ws);
sshLogger.info("WebSocket connection established", {
operation: "websocket_connection_established",
userId,
userConnections: userWs.size,
ip: req.socket.remoteAddress
});
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on("close", () => {
// Clean up user connection tracking
const userWs = userConnections.get(userId);
if (userWs) {
userWs.delete(ws);
@@ -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();
});
ws.on("message", (msg: RawData) => {
// Verify user still has data access before processing any messages
const currentDataKey = userCrypto.getUserDataKey(userId);
if (!currentDataKey) {
sshLogger.warn("WebSocket message rejected: data access expired", {
operation: "websocket_message_rejected",
userId,
reason: "data_access_expired"
reason: "data_access_expired",
});
ws.send(JSON.stringify({
type: "error",
message: "Data access expired - please re-authenticate",
code: "DATA_EXPIRED"
}));
ws.send(
JSON.stringify({
type: "error",
message: "Data access expired - please re-authenticate",
code: "DATA_EXPIRED",
}),
);
ws.close(1008, "Data access expired");
return;
}
@@ -225,7 +209,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
switch (type) {
case "connectToHost":
// Ensure userId is attached to hostConfig for secure credential resolution
if (data.hostConfig) {
data.hostConfig.userId = userId;
}
@@ -390,7 +373,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.privateKey || credential.key, // prefer new privateKey field
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,
@@ -480,16 +463,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
setupPingInterval();
// Change to initial path if specified
if (initialPath && initialPath.trim() !== "") {
// Send cd command to change directory
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
}
// Execute command if specified
if (executeCommand && executeCommand.trim() !== "") {
// Wait a moment for the cd command to complete, then execute the command
setTimeout(() => {
const command = `${executeCommand}\n`;
stream.write(command);
@@ -604,7 +583,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
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"],
},
};

View File

@@ -22,33 +22,27 @@ const app = express();
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:3000",
"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://")) {
return callback(null, true);
}
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) {
return callback(null, true);
}
// Check against allowed development origins
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Reject other origins
callback(new Error("Not allowed by CORS"));
},
credentials: true,
@@ -158,18 +152,15 @@ function getTunnelMarker(tunnelName: string) {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
}
function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void {
tunnelLogger.info(`Cleaning up resources for tunnel '${tunnelName}' (force=${forceCleanup})`);
// Prevent concurrent cleanup operations
function cleanupTunnelResources(
tunnelName: string,
forceCleanup = false,
): void {
if (cleanupInProgress.has(tunnelName)) {
tunnelLogger.info(`Cleanup already in progress for '${tunnelName}', skipping`);
return;
}
// Protect connecting tunnels unless forced
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
tunnelLogger.info(`Tunnel '${tunnelName}' is connecting, skipping cleanup (use force=true to override)`);
return;
}
@@ -183,8 +174,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
tunnelLogger.error(
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
);
} else {
tunnelLogger.info(`Successfully cleaned up remote tunnel processes for '${tunnelName}'`);
}
});
} else {
@@ -210,7 +199,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
try {
const conn = activeTunnels.get(tunnelName);
if (conn) {
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
conn.end();
}
} catch (e) {
@@ -220,7 +208,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
);
}
activeTunnels.delete(tunnelName);
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
}
if (tunnelVerifications.has(tunnelName)) {
@@ -454,10 +441,8 @@ async function connectSSHTunnel(
return;
}
// Mark tunnel as connecting to protect from cleanup
tunnelConnecting.add(tunnelName);
// Force cleanup any existing resources before new connection
cleanupTunnelResources(tunnelName, true);
if (retryAttempt === 0) {
@@ -519,7 +504,7 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
@@ -549,11 +534,10 @@ async function connectSSHTunnel(
authMethod: tunnelConfig.endpointAuthMethod,
};
tunnelLogger.info(`Source credentials for '${tunnelName}': authMethod=${resolvedSourceCredentials.authMethod}, hasPassword=${!!resolvedSourceCredentials.password}, hasSSHKey=${!!resolvedSourceCredentials.sshKey}`);
tunnelLogger.info(`Final endpoint credentials for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}, credentialId=${tunnelConfig.endpointCredentialId}`);
// Validate that we have usable endpoint credentials
if (resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password) {
if (
resolvedEndpointCredentials.authMethod === "password" &&
!resolvedEndpointCredentials.password
) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
@@ -564,7 +548,10 @@ async function connectSSHTunnel(
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.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
@@ -591,12 +578,11 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
operation: "tunnel_connect",
@@ -646,7 +632,6 @@ async function connectSSHTunnel(
clearTimeout(connectionTimeout);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
// Clear connecting state on error
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
@@ -677,7 +662,6 @@ async function connectSSHTunnel(
conn.on("close", () => {
clearTimeout(connectionTimeout);
// Clear connecting state on close
tunnelConnecting.delete(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}`;
}
tunnelLogger.info(`Executing tunnel command for '${tunnelName}': ${tunnelCmd.replace(/sshpass -p '[^']*'/g, 'sshpass -p [HIDDEN]').replace(/echo '[^']*'/g, 'echo [HIDDEN]')}`);
conn.exec(tunnelCmd, (err, stream) => {
if (err) {
tunnelLogger.error(
@@ -750,7 +732,6 @@ async function connectSSHTunnel(
!manualDisconnects.has(tunnelName) &&
activeTunnels.has(tunnelName)
) {
// Clear connecting state on successful connection
tunnelConnecting.delete(tunnelName);
broadcastTunnelStatus(tunnelName, {
@@ -827,7 +808,6 @@ async function connectSSHTunnel(
stream.stdout?.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
tunnelLogger.info(`SSH stdout for '${tunnelName}': ${output}`);
}
});
@@ -836,25 +816,42 @@ async function connectSSHTunnel(
stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
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 (errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found")) {
if (!isDebugMessage) {
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
}
if (
errorMsg.includes("sshpass: command not found") ||
errorMsg.includes("sshpass not found")
) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
reason:
"sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
});
}
// Check for port forwarding errors
if (errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed")) {
if (
errorMsg.includes("remote port forwarding failed") ||
errorMsg.includes("Error: remote port forwarding failed")
) {
const portMatch = errorMsg.match(/listen port (\d+)/);
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
tunnelLogger.error(`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`);
tunnelLogger.error(
`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`,
);
// Close the connection immediately to prevent retries
if (activeTunnels.has(tunnelName)) {
const conn = activeTunnels.get(tunnelName);
if (conn) {
@@ -905,7 +902,14 @@ async function connectSSHTunnel(
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
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"],
},
};
@@ -975,9 +979,7 @@ async function killRemoteTunnelByMarker(
callback: (err?: Error) => void,
) {
const tunnelMarker = getTunnelMarker(tunnelName);
tunnelLogger.info(`Attempting to kill remote tunnel processes with marker '${tunnelMarker}' on source host ${tunnelConfig.sourceIP}`);
// Resolve source credentials using same logic as main tunnel connection
let resolvedSourceCredentials = {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
@@ -1049,7 +1051,14 @@ async function killRemoteTunnelByMarker(
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
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"],
},
};
@@ -1085,7 +1094,6 @@ async function killRemoteTunnelByMarker(
}
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`;
conn.exec(checkCmd, (err, stream) => {
@@ -1095,31 +1103,27 @@ async function killRemoteTunnelByMarker(
const output = data.toString().trim();
if (output) {
foundProcesses = true;
tunnelLogger.info(`Found running tunnel processes for '${tunnelName}': ${output}`);
}
});
stream.on("close", () => {
if (!foundProcesses) {
tunnelLogger.info(`No running tunnel processes found for '${tunnelName}', cleanup not needed`);
conn.end();
callback();
return;
}
// Execute kill commands sequentially for better control
const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, // Force kill after delay
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
];
let commandIndex = 0;
function executeNextKillCommand() {
if (commandIndex >= killCmds.length) {
// Final verification
conn.exec(checkCmd, (err, verifyStream) => {
let stillRunning = false;
@@ -1127,15 +1131,17 @@ async function killRemoteTunnelByMarker(
const output = data.toString().trim();
if (output) {
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", () => {
if (!stillRunning) {
tunnelLogger.info(`All tunnel processes successfully terminated for '${tunnelName}'`);
} else {
tunnelLogger.warn(`Some tunnel processes may still be running for '${tunnelName}'`);
if (stillRunning) {
tunnelLogger.warn(
`Some tunnel processes may still be running for '${tunnelName}'`,
);
}
conn.end();
callback();
@@ -1148,13 +1154,13 @@ async function killRemoteTunnelByMarker(
conn.exec(killCmd, (err, stream) => {
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 {
tunnelLogger.info(`Executed kill command ${commandIndex + 1} for '${tunnelName}': ${killCmd.replace(/sleep \d+ && /, '')}`);
}
stream.on("close", (code) => {
tunnelLogger.info(`Kill command ${commandIndex + 1} completed with code ${code} for '${tunnelName}'`);
commandIndex++;
executeNextKillCommand();
});
@@ -1162,14 +1168,15 @@ async function killRemoteTunnelByMarker(
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
tunnelLogger.info(`Kill command ${commandIndex + 1} output for '${tunnelName}': ${output}`);
}
});
stream.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output && !output.includes("debug1")) {
tunnelLogger.warn(`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`);
tunnelLogger.warn(
`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`,
);
}
});
});
@@ -1181,7 +1188,9 @@ async function killRemoteTunnelByMarker(
});
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);
});
@@ -1212,8 +1221,6 @@ app.post("/ssh/tunnel/connect", (req, res) => {
const tunnelName = tunnelConfig.name;
// Clean up any existing resources before starting new connection
tunnelLogger.info(`Starting new connection for '${tunnelName}', cleaning up any existing resources`);
cleanupTunnelResources(tunnelName);
manualDisconnects.delete(tunnelName);
@@ -1247,8 +1254,6 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
activeRetryTimers.delete(tunnelName);
}
// Immediately clean up active connections (force cleanup)
tunnelLogger.info(`Manual disconnect requested for '${tunnelName}', cleaning up resources`);
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
@@ -1287,8 +1292,6 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
countdownIntervals.delete(tunnelName);
}
// Immediately clean up active connections for cancel operation too (force cleanup)
tunnelLogger.info(`Cancel requested for '${tunnelName}', cleaning up resources`);
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
@@ -1309,11 +1312,9 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
async function initializeAutoStartTunnels(): Promise<void> {
try {
// Get internal auth token from SystemCrypto
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken();
// Get autostart hosts for tunnel configs
const autostartResponse = await axios.get(
"http://localhost: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(
"http://localhost:30001/ssh/db/host/internal/all",
{
@@ -1339,7 +1339,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
const allHosts: SSHHost[] = allHostsResponse.data || [];
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) {
if (host.enableTunnel && host.tunnelConnections) {
@@ -1352,50 +1354,39 @@ async function initializeAutoStartTunnels(): Promise<void> {
);
if (endpointHost) {
tunnelLogger.info(`Setting up tunnel credentials for '${host.name || `${host.username}@${host.ip}`}' -> '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}': sourceAutostart=${!!host.autostartPassword}, endpointAutostart=${!!endpointHost.autostartPassword}, endpointEncrypted=${!!endpointHost.password}`);
// Debug: Log actual credential availability
tunnelLogger.info(`Source host credentials debug:`, {
hostId: host.id,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType
});
tunnelLogger.info(`Endpoint host credentials debug:`, {
hostId: endpointHost.id,
hasAutostartPassword: !!endpointHost.autostartPassword,
hasAutostartKey: !!endpointHost.autostartKey,
hasEncryptedPassword: !!endpointHost.password,
hasEncryptedKey: !!endpointHost.key,
authType: endpointHost.authType
});
const tunnelConfig: TunnelConfig = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
// Prefer autostart credentials for source host, fallback to encrypted credentials
sourcePassword: host.autostartPassword || host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.autostartKey || host.key,
sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
sourceKeyPassword:
host.autostartKeyPassword || host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
// Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointPassword:
tunnelConnection.endpointPassword ||
endpointHost.autostartPassword ||
endpointHost.password,
endpointAuthMethod:
tunnelConnection.endpointAuthType || endpointHost.authType,
endpointSSHKey:
tunnelConnection.endpointKey ||
endpointHost.autostartKey ||
endpointHost.key,
endpointKeyPassword:
tunnelConnection.endpointKeyPassword ||
endpointHost.autostartKeyPassword ||
endpointHost.keyPassword,
endpointKeyType:
tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnelConnection.sourcePort,
@@ -1406,24 +1397,30 @@ async function initializeAutoStartTunnels(): Promise<void> {
isPinned: host.pin,
};
// Validate source and endpoint credentials availability
const hasSourcePassword = host.autostartPassword;
const hasSourceKey = host.autostartKey;
const hasEndpointPassword = tunnelConnection.endpointPassword || endpointHost.autostartPassword;
const hasEndpointKey = tunnelConnection.endpointKey || endpointHost.autostartKey;
const hasEndpointPassword =
tunnelConnection.endpointPassword ||
endpointHost.autostartPassword;
const hasEndpointKey =
tunnelConnection.endpointKey || endpointHost.autostartKey;
if (!hasSourcePassword && !hasSourceKey) {
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`);
tunnelLogger.warn(
`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`,
);
}
if (!hasEndpointPassword && !hasEndpointKey) {
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`);
tunnelLogger.warn(
`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`,
);
}
autoStartTunnels.push(tunnelConfig);
} else {
tunnelLogger.error(
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map(h => h.name || `${h.username}@${h.ip}`).join(', ')}`,
`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) {
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
@@ -1454,10 +1449,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
const PORT = 30003;
app.listen(PORT, () => {
tunnelLogger.success("SSH Tunnel API server started", {
operation: "server_start",
port: PORT,
});
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);

View File

@@ -1,6 +1,3 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import "dotenv/config";
import dotenv from "dotenv";
import { promises as fs } from "fs";
@@ -13,33 +10,15 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
(async () => {
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 envPath = path.join(dataDir, ".env");
try {
await fs.access(envPath);
// Load the persistent .env file and override process.env
const persistentConfig = dotenv.config({ path: envPath });
if (persistentConfig.parsed) {
// Override process.env with values from persistent config
Object.assign(process.env, persistentConfig.parsed);
}
systemLogger.info(`Loaded persistent configuration from ${envPath}`, {
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"
});
}
} catch {}
const version = process.env.VERSION || "unknown";
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
@@ -47,72 +26,73 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
version: version,
});
// Initialize system crypto keys FIRST (after .env is loaded)
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeInternalAuthToken();
// Auto-initialize SSL/TLS configuration
await AutoSSLSetup.initialize();
// Initialize database first - required before other services
systemLogger.info("Initializing database...", {
operation: "database_init"
});
const dbModule = await import("./database/db/index.js");
await dbModule.initializeDatabase();
systemLogger.success("Database initialized successfully", {
operation: "database_init_complete"
});
// Production environment security checks
if (process.env.NODE_ENV === 'production') {
systemLogger.info("Running production environment security checks...", {
operation: "security_checks",
});
if (process.env.NODE_ENV === "production") {
const securityIssues: string[] = [];
// Check JWT and database keys (auto-generated if missing - warnings only)
if (!process.env.JWT_SECRET) {
systemLogger.warn("JWT_SECRET not set - using auto-generated keys (consider setting for production)", {
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments"
});
systemLogger.warn(
"JWT_SECRET not set - using auto-generated keys (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments",
},
);
} else if (process.env.JWT_SECRET.length < 64) {
securityIssues.push("JWT_SECRET should be at least 64 characters in production");
securityIssues.push(
"JWT_SECRET should be at least 64 characters in production",
);
}
if (!process.env.DATABASE_KEY) {
systemLogger.warn("DATABASE_KEY not set - using auto-generated keys (consider setting for production)", {
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments"
});
systemLogger.warn(
"DATABASE_KEY not set - using auto-generated keys (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments",
},
);
} else if (process.env.DATABASE_KEY.length < 64) {
securityIssues.push("DATABASE_KEY should be at least 64 characters in production");
securityIssues.push(
"DATABASE_KEY should be at least 64 characters in production",
);
}
if (!process.env.INTERNAL_AUTH_TOKEN) {
systemLogger.warn("INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)", {
operation: "security_warning",
note: "Auto-generated tokens are secure but not persistent across deployments"
});
systemLogger.warn(
"INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated tokens are secure but not persistent across deployments",
},
);
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
securityIssues.push("INTERNAL_AUTH_TOKEN should be at least 32 characters in production");
securityIssues.push(
"INTERNAL_AUTH_TOKEN should be at least 32 characters in production",
);
}
// Check database file encryption
if (process.env.DB_FILE_ENCRYPTION === 'false') {
securityIssues.push("Database file encryption should be enabled in production");
if (process.env.DB_FILE_ENCRYPTION === "false") {
securityIssues.push(
"Database file encryption should be enabled in production",
);
}
// Check CORS configuration warning
systemLogger.warn("Production deployment detected - ensure CORS is properly configured", {
operation: "security_checks",
warning: "Verify frontend domain whitelist"
});
systemLogger.warn(
"Production deployment detected - ensure CORS is properly configured",
{
operation: "security_checks",
warning: "Verify frontend domain whitelist",
},
);
if (securityIssues.length > 0) {
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
@@ -127,59 +107,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
});
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();
await authManager.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");
// Load modules that depend on database and encryption
systemLogger.info("Starting SSH services...", {
operation: "ssh_services_init"
});
await import("./ssh/terminal.js");
await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js");
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", () => {
systemLogger.info(
"Received SIGINT signal, initiating graceful shutdown...",

View File

@@ -23,27 +23,16 @@ interface JWTPayload {
exp?: number;
}
/**
* AuthManager - Simplified authentication manager
*
* Responsibilities:
* - JWT generation and validation
* - Authentication middleware
* - User login/logout
*
* No more two-layer sessions - use UserKeyManager directly
*/
class AuthManager {
private static instance: AuthManager;
private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = new Set(); // Track invalidated JWT tokens
private invalidatedTokens: Set<string> = new Set();
private constructor() {
this.systemCrypto = SystemCrypto.getInstance();
this.userCrypto = UserCrypto.getInstance();
// Set up callback to invalidate JWT tokens when data sessions expire
this.userCrypto.setSessionExpiredCallback((userId: string) => {
this.invalidateUserTokens(userId);
});
@@ -56,80 +45,58 @@ class AuthManager {
return this.instance;
}
/**
* Initialize authentication system
*/
async initialize(): Promise<void> {
await this.systemCrypto.initializeJWTSecret();
databaseLogger.info("AuthManager initialized", {
operation: "auth_init"
});
}
/**
* User registration
*/
async registerUser(userId: string, password: string): Promise<void> {
await this.userCrypto.setupUserEncryption(userId, password);
}
/**
* User login with lazy encryption migration
*/
async authenticateUser(userId: string, password: string): Promise<boolean> {
const authenticated = await this.userCrypto.authenticateUser(userId, password);
const authenticated = await this.userCrypto.authenticateUser(
userId,
password,
);
if (authenticated) {
// Trigger lazy encryption migration for user's sensitive fields
await this.performLazyEncryptionMigration(userId);
}
return authenticated;
}
/**
* Perform lazy encryption migration for user's sensitive data
* This runs asynchronously after successful login
*/
private async performLazyEncryptionMigration(userId: string): Promise<void> {
try {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.warn("Cannot perform lazy encryption migration - user data key not available", {
operation: "lazy_encryption_migration_no_key",
userId,
});
databaseLogger.warn(
"Cannot perform lazy encryption migration - user data key not available",
{
operation: "lazy_encryption_migration_no_key",
userId,
},
);
return;
}
// Import database connection - need to access raw SQLite for migration
const { getSqlite, saveMemoryDatabaseToFile } = await import("../database/db/index.js");
const { getSqlite, saveMemoryDatabaseToFile } = await import(
"../database/db/index.js"
);
// Database should already be initialized by starter.ts, but ensure we can access it
const sqlite = getSqlite();
// Perform the migration
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
userId,
userDataKey,
sqlite
sqlite,
);
if (migrationResult.migrated) {
// Save the in-memory database to disk to persist the migration
await saveMemoryDatabaseToFile();
databaseLogger.success("Lazy encryption migration completed for user", {
operation: "lazy_encryption_migration_success",
userId,
migratedTables: migrationResult.migratedTables,
migratedFieldsCount: migrationResult.migratedFieldsCount,
});
} else {
}
} catch (error) {
// Log error but don't fail the login process
databaseLogger.error("Lazy encryption migration failed", error, {
operation: "lazy_encryption_migration_error",
userId,
@@ -138,12 +105,9 @@ class AuthManager {
}
}
/**
* Generate JWT Token
*/
async generateJWTToken(
userId: string,
options: { expiresIn?: string; pendingTOTP?: boolean } = {}
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
@@ -153,21 +117,13 @@ class AuthManager {
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "24h"
expiresIn: options.expiresIn || "24h",
} as jwt.SignOptions);
}
/**
* Verify JWT Token
*/
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
try {
// Check if token is in invalidated list
if (this.invalidatedTokens.has(token)) {
databaseLogger.debug("JWT token is invalidated", {
operation: "jwt_verify_invalidated",
tokenPrefix: token.substring(0, 20) + "..."
});
return null;
}
@@ -177,58 +133,37 @@ class AuthManager {
} catch (error) {
databaseLogger.warn("JWT verification failed", {
operation: "jwt_verify_failed",
error: error instanceof Error ? error.message : 'Unknown error',
error: error instanceof Error ? error.message : "Unknown error",
});
return null;
}
}
/**
* Invalidate JWT token (add to blacklist)
*/
invalidateJWTToken(token: string): void {
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 {
// 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", {
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) {
return {
httpOnly: true, // Prevent XSS attacks
secure: req.secure || req.headers['x-forwarded-proto'] === 'https', // Detect HTTPS properly
sameSite: "strict" as const, // Prevent CSRF attacks
maxAge: maxAge, // Session duration in milliseconds
path: "/", // Available site-wide
httpOnly: true,
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const,
maxAge: maxAge,
path: "/",
};
}
/**
* Authentication middleware
*/
createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
// Try to get JWT from secure HttpOnly cookie first
let token = req.cookies?.jwt;
// Fallback to Authorization header for backward compatibility
if (!token) {
const authHeader = req.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
@@ -252,9 +187,6 @@ class AuthManager {
};
}
/**
* Data access middleware - requires user to have unlocked data
*/
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = (req as any).userId;
@@ -266,7 +198,7 @@ class AuthManager {
if (!dataKey) {
return res.status(401).json({
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() {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers["authorization"];
@@ -292,20 +221,25 @@ class AuthManager {
return res.status(401).json({ error: "Invalid token" });
}
// Check if user is admin
try {
const { db } = await import("../database/db/index.js");
const { users } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const user = await db.select().from(users).where(eq(users.id, payload.userId));
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId));
if (!user || user.length === 0 || !user[0].is_admin) {
databaseLogger.warn("Non-admin user attempted to access admin endpoint", {
operation: "admin_access_denied",
userId: payload.userId,
endpoint: req.path,
});
databaseLogger.warn(
"Non-admin user attempted to access admin endpoint",
{
operation: "admin_access_denied",
userId: payload.userId,
endpoint: req.path,
},
);
return res.status(403).json({ error: "Admin access required" });
}
@@ -317,38 +251,36 @@ class AuthManager {
operation: "admin_check_failed",
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 {
this.userCrypto.logoutUser(userId);
}
/**
* Get user data key
*/
getUserDataKey(userId: string): Buffer | null {
return this.userCrypto.getUserDataKey(userId);
}
/**
* Check if user is unlocked
*/
isUserUnlocked(userId: string): boolean {
return this.userCrypto.isUserUnlocked(userId);
}
/**
* Change user password
*/
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
return await this.userCrypto.changeUserPassword(userId, oldPassword, newPassword);
async changeUserPassword(
userId: string,
oldPassword: string,
newPassword: string,
): Promise<boolean> {
return await this.userCrypto.changeUserPassword(
userId,
oldPassword,
newPassword,
);
}
}
export { AuthManager, type AuthenticationResult, type JWTPayload };
export { AuthManager, type AuthenticationResult, type JWTPayload };

View File

@@ -4,123 +4,92 @@ import path from "path";
import crypto from "crypto";
import { systemLogger } from "./logger.js";
/**
* Auto SSL Setup - Optional SSL certificate generation for Termix
*
* Linus principle: Simple defaults, optional security features
* - SSL disabled by default to avoid setup complexity
* - Auto-generates SSL certificates when enabled
* - Uses container-appropriate paths
* - Users can enable SSL by setting ENABLE_SSL=true
*/
export class AutoSSLSetup {
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
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 KEY_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.key");
private static readonly CERT_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.crt",
);
private static readonly KEY_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.key",
);
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
/**
* Initialize SSL setup automatically during system startup
*/
static async initialize(): Promise<void> {
try {
systemLogger.info("Initializing SSL/TLS configuration...", {
operation: "ssl_auto_init"
});
// Check if SSL is already properly configured
if (await this.isSSLConfigured()) {
systemLogger.info("SSL configuration already exists and is valid", {
operation: "ssl_already_configured"
});
// Log certificate information for existing certificates
await this.logCertificateInfo();
return;
}
// Auto-generate SSL certificates
await this.generateSSLCertificates();
// Setup environment variables for SSL
await this.setupEnvironmentVariables();
systemLogger.success("SSL/TLS configuration completed successfully", {
operation: "ssl_auto_init_complete",
https_port: process.env.SSL_PORT || "8443",
note: "HTTPS/WSS is now enabled by default"
});
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed"
operation: "ssl_auto_init_failed",
});
// Don't crash the application - fallback to HTTP
systemLogger.warn("Falling back to HTTP-only mode", {
operation: "ssl_fallback_http"
operation: "ssl_fallback_http",
});
}
}
/**
* Check if SSL is already properly configured
*/
private static async isSSLConfigured(): Promise<boolean> {
try {
// Check if certificate files exist
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
// Check if certificate is still valid (at least 30 days)
execSync(`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, {
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
});
execSync(
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
{
stdio: "pipe",
},
);
return true;
} catch (error) {
if (error instanceof Error && error.message.includes('checkend')) {
systemLogger.warn("SSL certificate is expired or expiring soon, will regenerate", {
operation: "ssl_cert_expired",
cert_path: this.CERT_FILE,
error: error.message
});
if (error instanceof Error && error.message.includes("checkend")) {
systemLogger.warn(
"SSL certificate is expired or expiring soon, will regenerate",
{
operation: "ssl_cert_expired",
cert_path: this.CERT_FILE,
error: error.message,
},
);
} else {
systemLogger.info("SSL certificate not found or invalid, will generate new one", {
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE
});
systemLogger.info(
"SSL certificate not found or invalid, will generate new one",
{
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
},
);
}
return false;
}
}
/**
* Generate SSL certificates automatically
*/
private static async generateSSLCertificates(): Promise<void> {
systemLogger.info("Generating SSL certificates for local development...", {
operation: "ssl_cert_generation"
operation: "ssl_cert_generation",
});
try {
// Check if OpenSSL is available
try {
execSync('openssl version', { stdio: 'pipe' });
execSync("openssl version", { stdio: "pipe" });
} 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 });
// Create OpenSSL config for comprehensive certificate
const configFile = path.join(this.SSL_DIR, "openssl.conf");
const opensslConfig = `
[req]
@@ -155,102 +124,106 @@ IP.2 = ::1
await fs.writeFile(configFile, opensslConfig);
// Generate private key
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, { stdio: 'pipe' });
// Generate certificate
execSync(`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`, {
stdio: 'pipe'
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
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.CERT_FILE, 0o644);
// Clean up temp config
await fs.unlink(configFile);
systemLogger.success("SSL certificates generated successfully", {
operation: "ssl_cert_generated",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
valid_days: 365
valid_days: 365,
});
// Log certificate information
await this.logCertificateInfo();
} 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> {
try {
const subject = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -subject`, { stdio: 'pipe' }).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();
const subject = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
{ stdio: "pipe" },
)
.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:", {
operation: "ssl_cert_info",
subject: subject.replace('subject=', ''),
issuer: issuer.replace('issuer=', ''),
valid_from: notBefore.replace('notBefore=', ''),
valid_until: notAfter.replace('notAfter=', ''),
note: "Certificate will auto-renew 30 days before expiration"
subject: subject.replace("subject=", ""),
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
note: "Certificate will auto-renew 30 days before expiration",
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
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> {
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 keyPath = this.KEY_FILE;
const sslEnvVars = {
ENABLE_SSL: "false", // Disable SSL by default to avoid setup issues
ENABLE_SSL: "false",
SSL_PORT: process.env.SSL_PORT || "8443",
SSL_CERT_PATH: certPath,
SSL_KEY_PATH: keyPath,
SSL_DOMAIN: "localhost"
SSL_DOMAIN: "localhost",
};
// Check if .env file exists
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, 'utf8');
} catch {
// .env doesn't exist, will create new one
}
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch {}
// Update or add SSL variables
let updatedContent = envContent;
let hasChanges = false;
for (const [key, value] of Object.entries(sslEnvVars)) {
const regex = new RegExp(`^${key}=.*$`, 'm');
const regex = new RegExp(`^${key}=.*$`, "m");
if (regex.test(updatedContent)) {
// Update existing variable
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
} else {
// Add new variable
if (!updatedContent.includes(`# SSL Configuration`)) {
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
}
@@ -259,59 +232,28 @@ IP.2 = ::1
}
}
// Write updated .env file if there are changes
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", {
operation: "ssl_env_configured",
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)) {
process.env[key] = value;
}
}
/**
* Get SSL configuration for nginx/server
*/
static getSSLConfig() {
return {
enabled: process.env.ENABLE_SSL === "true",
port: parseInt(process.env.SSL_PORT || "8443"),
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
domain: process.env.SSL_DOMAIN || "localhost"
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 ║
╚══════════════════════════════════════════════════════════════╝
`);
}
}
}
}

View File

@@ -3,31 +3,21 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js";
import { UserCrypto } from "./user-crypto.js";
import { databaseLogger } from "./logger.js";
/**
* DataCrypto - Simplified database encryption
*
* Linus principles:
* - Remove all "backward compatibility" garbage
* - Remove all special case handling
* - Data is either properly encrypted or operation fails
* - No legacy data concept
*/
class DataCrypto {
private static userCrypto: UserCrypto;
static initialize() {
this.userCrypto = UserCrypto.getInstance();
databaseLogger.info("DataCrypto initialized - no legacy compatibility", {
operation: "data_crypto_init",
});
}
/**
* Encrypt record - simple and direct
*/
static encryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
static encryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
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)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
@@ -35,7 +25,7 @@ class DataCrypto {
value as string,
userDataKey,
recordId,
fieldName
fieldName,
);
}
}
@@ -43,11 +33,12 @@ class DataCrypto {
return encryptedRecord;
}
/**
* Decrypt record with lazy encryption support
* Handles both encrypted and plaintext fields (from migration)
*/
static decryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
static decryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
if (!record) return record;
const decryptedRecord = { ...record };
@@ -55,12 +46,11 @@ class DataCrypto {
for (const [fieldName, value] of Object.entries(record)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
// Use lazy encryption to handle both plaintext and encrypted data
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
value as string,
userDataKey,
recordId,
fieldName
fieldName,
);
}
}
@@ -68,22 +58,22 @@ class DataCrypto {
return decryptedRecord;
}
/**
* Batch decrypt
*/
static decryptRecords(tableName: string, records: any[], userId: string, userDataKey: Buffer): any[] {
static decryptRecords(
tableName: string,
records: any[],
userId: string,
userDataKey: Buffer,
): any[] {
if (!Array.isArray(records)) return records;
return records.map((record) => this.decryptRecord(tableName, record, userId, userDataKey));
return records.map((record) =>
this.decryptRecord(tableName, record, userId, userDataKey),
);
}
/**
* Migrate user's plaintext sensitive fields to encrypted format
* Called during user login to gradually encrypt legacy data
*/
static async migrateUserSensitiveFields(
userId: string,
userDataKey: Buffer,
db: any
db: any,
): Promise<{
migrated: boolean;
migratedTables: string[];
@@ -94,45 +84,32 @@ class DataCrypto {
let migratedFieldsCount = 0;
try {
databaseLogger.info("Starting user sensitive fields migration", {
operation: "user_sensitive_migration_start",
userId,
});
// Check if migration is needed
const { needsMigration, plaintextFields } = await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
db
);
const { needsMigration, plaintextFields } =
await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
db,
);
if (!needsMigration) {
databaseLogger.info("No migration needed for user", {
operation: "user_sensitive_migration_not_needed",
userId,
});
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
databaseLogger.info("User requires sensitive field migration", {
operation: "user_sensitive_migration_required",
userId,
plaintextFieldsCount: plaintextFields.length,
});
// Process ssh_data table
const sshDataRecords = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
const sshDataRecords = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
for (const record of sshDataRecords) {
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_data');
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString()
);
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString(),
);
if (needsUpdate) {
// Update the record in database
const updateQuery = `
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
@@ -142,30 +119,32 @@ class DataCrypto {
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
record.id
record.id,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes('ssh_data')) {
migratedTables.push('ssh_data');
if (!migratedTables.includes("ssh_data")) {
migratedTables.push("ssh_data");
}
migrated = true;
}
}
// Process ssh_credentials table
const sshCredentialsRecords = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
const sshCredentialsRecords = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
for (const record of sshCredentialsRecords) {
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_credentials');
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString()
);
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
record,
sensitiveFields,
userDataKey,
record.id.toString(),
);
if (needsUpdate) {
// Update the record in database
const updateQuery = `
UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
@@ -176,30 +155,32 @@ class DataCrypto {
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.private_key || null,
record.id
record.id,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes('ssh_credentials')) {
migratedTables.push('ssh_credentials');
if (!migratedTables.includes("ssh_credentials")) {
migratedTables.push("ssh_credentials");
}
migrated = true;
}
}
// Process users table
const userRecord = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
const userRecord = db
.prepare("SELECT * FROM users WHERE id = ?")
.get(userId);
if (userRecord) {
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('users');
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
userRecord,
sensitiveFields,
userDataKey,
userId
);
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("users");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
userRecord,
sensitiveFields,
userDataKey,
userId,
);
if (needsUpdate) {
// Update the record in database
const updateQuery = `
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?
@@ -208,28 +189,18 @@ class DataCrypto {
db.prepare(updateQuery).run(
updatedRecord.totp_secret || null,
updatedRecord.totp_backup_codes || null,
userId
userId,
);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes('users')) {
migratedTables.push('users');
if (!migratedTables.includes("users")) {
migratedTables.push("users");
}
migrated = true;
}
}
if (migrated) {
databaseLogger.success("User sensitive fields migration completed", {
operation: "user_sensitive_migration_success",
userId,
migratedTables,
migratedFieldsCount,
});
}
return { migrated, migratedTables, migratedFieldsCount };
} catch (error) {
databaseLogger.error("User sensitive fields migration failed", error, {
operation: "user_sensitive_migration_failed",
@@ -237,21 +208,14 @@ class DataCrypto {
error: error instanceof Error ? error.message : "Unknown error",
});
// Don't throw error to avoid breaking user login
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
}
/**
* Get user data key
*/
static getUserDataKey(userId: string): Buffer | null {
return this.userCrypto.getUserDataKey(userId);
}
/**
* Verify user access permissions - simple and direct
*/
static validateUserAccess(userId: string): Buffer {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) {
@@ -260,48 +224,55 @@ class DataCrypto {
return userDataKey;
}
/**
* Convenience method: automatically get user key and encrypt
*/
static encryptRecordForUser(tableName: string, record: any, userId: string): any {
static encryptRecordForUser(
tableName: string,
record: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.encryptRecord(tableName, record, userId, userDataKey);
}
/**
* Convenience method: automatically get user key and decrypt
*/
static decryptRecordForUser(tableName: string, record: any, userId: string): any {
static decryptRecordForUser(
tableName: string,
record: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecord(tableName, record, userId, userDataKey);
}
/**
* Convenience method: batch decrypt
*/
static decryptRecordsForUser(tableName: string, records: any[], userId: string): any[] {
static decryptRecordsForUser(
tableName: string,
records: any[],
userId: string,
): any[] {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecords(tableName, records, userId, userDataKey);
}
/**
* Check if user can access data
*/
static canUserAccessData(userId: string): boolean {
return this.userCrypto.isUserUnlocked(userId);
}
/**
* Test encryption functionality
*/
static testUserEncryption(userId: string): boolean {
try {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) return false;
const testData = "test-" + Date.now();
const encrypted = FieldCrypto.encryptField(testData, userDataKey, "test-record", "test-field");
const decrypted = FieldCrypto.decryptField(encrypted, userDataKey, "test-record", "test-field");
const encrypted = FieldCrypto.encryptField(
testData,
userDataKey,
"test-record",
"test-field",
);
const decrypted = FieldCrypto.decryptField(
encrypted,
userDataKey,
"test-record",
"test-field",
);
return decrypted === testData;
} catch (error) {
@@ -310,4 +281,4 @@ class DataCrypto {
}
}
export { DataCrypto };
export { DataCrypto };

View File

@@ -10,19 +10,10 @@ interface EncryptedFileMetadata {
version: string;
fingerprint: string;
algorithm: string;
keySource?: string; // Track where the key comes from (SystemCrypto) - v2 only
salt?: string; // Legacy v1 format only
keySource?: string;
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 {
private static readonly VERSION = "v2";
private static readonly ALGORITHM = "aes-256-gcm";
@@ -30,33 +21,28 @@ class DatabaseFileEncryption {
private static readonly METADATA_FILE_SUFFIX = ".meta";
private static systemCrypto = SystemCrypto.getInstance();
/**
* Encrypt database from buffer (for in-memory databases)
*/
static async encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): Promise<string> {
static async encryptDatabaseFromBuffer(
buffer: Buffer,
targetPath: string,
): Promise<string> {
try {
// Get database key from SystemCrypto (no more fixed seed garbage!)
const key = await this.systemCrypto.getDatabaseKey();
// Generate encryption components
const iv = crypto.randomBytes(16);
// Encrypt the buffer
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
const tag = cipher.getAuthTag();
// Create metadata
const metadata: EncryptedFileMetadata = {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
version: this.VERSION,
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
};
// Write encrypted file and metadata
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
fs.writeFileSync(targetPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
@@ -73,10 +59,10 @@ class DatabaseFileEncryption {
}
}
/**
* Encrypt database file
*/
static async encryptDatabaseFile(sourcePath: string, targetPath?: string): Promise<string> {
static async encryptDatabaseFile(
sourcePath: string,
targetPath?: string,
): Promise<string> {
if (!fs.existsSync(sourcePath)) {
throw new Error(`Source database file does not exist: ${sourcePath}`);
}
@@ -86,16 +72,12 @@ class DatabaseFileEncryption {
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
try {
// Read source file
const sourceData = fs.readFileSync(sourcePath);
// Get database key from SystemCrypto (no more fixed seed garbage!)
const key = await this.systemCrypto.getDatabaseKey();
// Generate encryption components
const iv = crypto.randomBytes(16);
// Encrypt the file
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
const encrypted = Buffer.concat([
cipher.update(sourceData),
@@ -103,17 +85,15 @@ class DatabaseFileEncryption {
]);
const tag = cipher.getAuthTag();
// Create metadata
const metadata: EncryptedFileMetadata = {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
version: this.VERSION,
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
};
// Write encrypted file and metadata
fs.writeFileSync(encryptedPath, encrypted);
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> {
if (!fs.existsSync(encryptedPath)) {
throw new Error(
@@ -155,35 +132,33 @@ class DatabaseFileEncryption {
}
try {
// Read metadata
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
// Read encrypted data
const encryptedData = fs.readFileSync(encryptedPath);
// Get decryption key based on version
let key: Buffer;
if (metadata.version === "v2") {
// New v2 format: use SystemCrypto key
key = await this.systemCrypto.getDatabaseKey();
} else if (metadata.version === "v1") {
// Legacy v1 format: use deprecated salt-based key derivation
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
operation: "decrypt_legacy_v1",
path: encryptedPath
});
databaseLogger.warn(
"Decrypting legacy v1 encrypted database - consider upgrading",
{
operation: "decrypt_legacy_v1",
path: encryptedPath,
},
);
if (!metadata.salt) {
throw new Error("v1 encrypted file missing required salt field");
}
const salt = Buffer.from(metadata.salt, "hex");
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
const fixedSeed =
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
} else {
throw new Error(`Unsupported encryption version: ${metadata.version}`);
}
// Decrypt to buffer
const decipher = crypto.createDecipheriv(
metadata.algorithm,
key,
@@ -208,9 +183,6 @@ class DatabaseFileEncryption {
}
}
/**
* Decrypt database file
*/
static async decryptDatabaseFile(
encryptedPath: string,
targetPath?: string,
@@ -230,35 +202,33 @@ class DatabaseFileEncryption {
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
try {
// Read metadata
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
// Read encrypted data
const encryptedData = fs.readFileSync(encryptedPath);
// Get decryption key based on version
let key: Buffer;
if (metadata.version === "v2") {
// New v2 format: use SystemCrypto key
key = await this.systemCrypto.getDatabaseKey();
} else if (metadata.version === "v1") {
// Legacy v1 format: use deprecated salt-based key derivation
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
operation: "decrypt_legacy_v1",
path: encryptedPath
});
databaseLogger.warn(
"Decrypting legacy v1 encrypted database - consider upgrading",
{
operation: "decrypt_legacy_v1",
path: encryptedPath,
},
);
if (!metadata.salt) {
throw new Error("v1 encrypted file missing required salt field");
}
const salt = Buffer.from(metadata.salt, "hex");
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
const fixedSeed =
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
} else {
throw new Error(`Unsupported encryption version: ${metadata.version}`);
}
// Decrypt the file
const decipher = crypto.createDecipheriv(
metadata.algorithm,
key,
@@ -271,7 +241,6 @@ class DatabaseFileEncryption {
decipher.final(),
]);
// Write decrypted file
fs.writeFileSync(decryptedPath, decrypted);
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 {
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
@@ -318,9 +284,6 @@ class DatabaseFileEncryption {
}
}
/**
* Get information about an encrypted database file
*/
static getEncryptedFileInfo(encryptedPath: string): {
version: string;
algorithm: string;
@@ -338,13 +301,13 @@ class DatabaseFileEncryption {
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileStats = fs.statSync(encryptedPath);
const currentFingerprint = "termix-v1-file"; // Fixed identifier
const currentFingerprint = "termix-v1-file";
return {
version: metadata.version,
algorithm: metadata.algorithm,
fingerprint: metadata.fingerprint,
isCurrentHardware: true, // Hardware validation removed
isCurrentHardware: true,
fileSize: fileStats.size,
};
} catch {
@@ -352,9 +315,6 @@ class DatabaseFileEncryption {
}
}
/**
* Securely backup database by creating encrypted copy
*/
static async createEncryptedBackup(
databasePath: string,
backupDir: string,
@@ -363,25 +323,19 @@ class DatabaseFileEncryption {
throw new Error(`Database file does not exist: ${databasePath}`);
}
// Ensure backup directory exists
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
// Generate backup filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
const backupPath = path.join(backupDir, backupFileName);
try {
const encryptedPath = await this.encryptDatabaseFile(databasePath, backupPath);
databaseLogger.info("Encrypted database backup created", {
operation: "database_backup",
sourcePath: databasePath,
backupPath: encryptedPath,
timestamp,
});
const encryptedPath = await this.encryptDatabaseFile(
databasePath,
backupPath,
);
return encryptedPath;
} catch (error) {
@@ -394,9 +348,6 @@ class DatabaseFileEncryption {
}
}
/**
* Restore database from encrypted backup
*/
static async restoreFromEncryptedBackup(
backupPath: string,
targetPath: string,
@@ -406,13 +357,10 @@ class DatabaseFileEncryption {
}
try {
const restoredPath = await this.decryptDatabaseFile(backupPath, targetPath);
databaseLogger.info("Database restored from encrypted backup", {
operation: "database_restore",
const restoredPath = await this.decryptDatabaseFile(
backupPath,
restoredPath,
});
targetPath,
);
return restoredPath;
} catch (error) {
@@ -425,10 +373,6 @@ class DatabaseFileEncryption {
}
}
/**
* Clean up temporary files
*/
static cleanupTempFiles(basePath: string): void {
try {
const tempFiles = [

View File

@@ -32,12 +32,11 @@ export class DatabaseMigration {
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
}
/**
* 检查是否需要迁移以及迁移状态
*/
checkMigrationStatus(): MigrationStatus {
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath);
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(
this.encryptedDbPath,
);
let unencryptedDbSize = 0;
if (hasUnencryptedDb) {
@@ -51,24 +50,21 @@ export class DatabaseMigration {
}
}
// 确定迁移状态
let needsMigration = false;
let reason = "";
if (hasEncryptedDb && hasUnencryptedDb) {
// 两个都存在:可能是之前迁移失败或中断
needsMigration = false;
reason = "Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
reason =
"Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
} else if (hasEncryptedDb && !hasUnencryptedDb) {
// 只有加密数据库:无需迁移
needsMigration = false;
reason = "Only encrypted database exists. No migration needed.";
} else if (!hasEncryptedDb && hasUnencryptedDb) {
// 只有未加密数据库:需要迁移
needsMigration = true;
reason = "Unencrypted database found. Migration to encrypted format required.";
reason =
"Unencrypted database found. Migration to encrypted format required.";
} else {
// 都不存在:全新安装
needsMigration = false;
reason = "No existing database found. This is a fresh installation.";
}
@@ -82,36 +78,22 @@ export class DatabaseMigration {
};
}
/**
* 创建未加密数据库的安全备份
*/
private createBackup(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
try {
databaseLogger.info("Creating migration backup", {
operation: "migration_backup_create",
source: this.unencryptedDbPath,
backup: backupPath,
});
fs.copyFileSync(this.unencryptedDbPath, backupPath);
// 验证备份完整性
const originalSize = fs.statSync(this.unencryptedDbPath).size;
const backupSize = fs.statSync(backupPath).size;
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;
} catch (error) {
databaseLogger.error("Failed to create migration backup", error, {
@@ -119,79 +101,81 @@ export class DatabaseMigration {
source: this.unencryptedDbPath,
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): Promise<boolean> {
private async verifyMigration(
originalDb: Database.Database,
memoryDb: Database.Database,
): Promise<boolean> {
try {
databaseLogger.info("Verifying migration integrity", {
operation: "migration_verify_start",
});
// 临时禁用外键约束以进行验证查询
memoryDb.exec("PRAGMA foreign_keys = OFF");
// 获取原数据库的表列表
const originalTables = originalDb
.prepare(`
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`)
`,
)
.all() as { name: string }[];
// 获取内存数据库的表列表
const memoryTables = memoryDb
.prepare(`
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`)
`,
)
.all() as { name: string }[];
// 检查表数量是否一致
if (originalTables.length !== memoryTables.length) {
databaseLogger.error("Table count mismatch during migration verification", null, {
operation: "migration_verify_failed",
originalCount: originalTables.length,
memoryCount: memoryTables.length,
});
databaseLogger.error(
"Table count mismatch during migration verification",
null,
{
operation: "migration_verify_failed",
originalCount: originalTables.length,
memoryCount: memoryTables.length,
},
);
return false;
}
let totalOriginalRows = 0;
let totalMemoryRows = 0;
// 逐表验证行数
for (const table of originalTables) {
const originalCount = originalDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
const memoryCount = memoryDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
const originalCount = originalDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
const memoryCount = memoryDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
totalOriginalRows += originalCount.count;
totalMemoryRows += memoryCount.count;
if (originalCount.count !== memoryCount.count) {
databaseLogger.error("Row count mismatch for table during migration verification", null, {
operation: "migration_verify_table_failed",
table: table.name,
originalRows: originalCount.count,
memoryRows: memoryCount.count,
});
databaseLogger.error(
"Row count mismatch for table during migration verification",
null,
{
operation: "migration_verify_table_failed",
table: table.name,
originalRows: originalCount.count,
memoryRows: memoryCount.count,
},
);
return false;
}
}
databaseLogger.success("Migration integrity verification completed", {
operation: "migration_verify_success",
tables: originalTables.length,
totalRows: totalOriginalRows,
});
// 重新启用外键约束
memoryDb.exec("PRAGMA foreign_keys = ON");
return true;
@@ -203,9 +187,6 @@ export class DatabaseMigration {
}
}
/**
* 执行数据库迁移
*/
async migrateDatabase(): Promise<MigrationResult> {
const startTime = Date.now();
let backupPath: string | undefined;
@@ -213,49 +194,31 @@ export class DatabaseMigration {
let migratedRows = 0;
try {
databaseLogger.info("Starting database migration from unencrypted to encrypted format", {
operation: "migration_start",
source: this.unencryptedDbPath,
target: this.encryptedDbPath,
});
// 1. 创建安全备份
backupPath = this.createBackup();
// 2. 打开原数据库(只读)
const originalDb = new Database(this.unencryptedDbPath, { readonly: true });
const originalDb = new Database(this.unencryptedDbPath, {
readonly: true,
});
// 3. 创建内存数据库
const memoryDb = new Database(":memory:");
try {
// 4. 获取所有表结构
const tables = originalDb
.prepare(`
.prepare(
`
SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`)
`,
)
.all() as { name: string; sql: string }[];
databaseLogger.info("Found tables to migrate", {
operation: "migration_tables_found",
tableCount: tables.length,
tables: tables.map(t => t.name),
});
// 5. 在内存数据库中创建表结构
for (const table of tables) {
memoryDb.exec(table.sql);
migratedTables++;
}
// 6. 禁用外键约束以避免插入顺序问题
databaseLogger.info("Disabling foreign key constraints for migration", {
operation: "migration_disable_fk",
});
memoryDb.exec("PRAGMA foreign_keys = OFF");
// 7. 复制每个表的数据
for (const table of tables) {
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
@@ -263,66 +226,64 @@ export class DatabaseMigration {
const columns = Object.keys(rows[0]);
const placeholders = columns.map(() => "?").join(", ");
const insertStmt = memoryDb.prepare(
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
);
// 使用事务批量插入
const insertTransaction = memoryDb.transaction((dataRows: any[]) => {
for (const row of dataRows) {
const values = columns.map((col) => row[col]);
insertStmt.run(values);
}
});
const insertTransaction = memoryDb.transaction(
(dataRows: any[]) => {
for (const row of dataRows) {
const values = columns.map((col) => row[col]);
insertStmt.run(values);
}
},
);
insertTransaction(rows);
migratedRows += rows.length;
}
}
// 8. 重新启用外键约束
databaseLogger.info("Re-enabling foreign key constraints after migration", {
operation: "migration_enable_fk",
});
memoryDb.exec("PRAGMA foreign_keys = ON");
// 验证外键约束现在是否正常
const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all();
const fkCheckResult = memoryDb
.prepare("PRAGMA foreign_key_check")
.all();
if (fkCheckResult.length > 0) {
databaseLogger.error("Foreign key constraints violations detected after migration", null, {
operation: "migration_fk_check_failed",
violations: fkCheckResult,
});
throw new Error(`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`);
databaseLogger.error(
"Foreign key constraints violations detected after migration",
null,
{
operation: "migration_fk_check_failed",
violations: fkCheckResult,
},
);
throw new Error(
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
);
}
databaseLogger.success("Foreign key constraints verification passed", {
operation: "migration_fk_check_success",
});
// 9. 验证迁移完整性
const verificationPassed = await this.verifyMigration(originalDb, memoryDb);
const verificationPassed = await this.verifyMigration(
originalDb,
memoryDb,
);
if (!verificationPassed) {
throw new Error("Migration integrity verification failed");
}
// 10. 导出内存数据库到缓冲区
const buffer = memoryDb.serialize();
// 11. 创建加密数据库文件
databaseLogger.info("Creating encrypted database file", {
operation: "migration_encrypt_start",
bufferSize: buffer.length,
});
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
this.encryptedDbPath,
);
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, this.encryptedDbPath);
// 12. 验证加密文件
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)) {
if (
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
) {
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}`;
fs.renameSync(this.unencryptedDbPath, migratedPath);
@@ -344,15 +305,13 @@ export class DatabaseMigration {
backupPath,
duration: Date.now() - startTime,
};
} finally {
// 确保数据库连接关闭
originalDb.close();
memoryDb.close();
}
} 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, {
operation: "migration_failed",
@@ -373,34 +332,33 @@ export class DatabaseMigration {
}
}
/**
* 清理旧的备份文件保留最近3个
*/
cleanupOldBackups(): void {
try {
const backupPattern = /\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const migratedPattern = /\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const backupPattern =
/\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const migratedPattern =
/\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
const files = fs.readdirSync(this.dataDir);
// 查找备份文件和已迁移文件
const backupFiles = files.filter(f => backupPattern.test(f))
.map(f => ({
const backupFiles = files
.filter((f) => backupPattern.test(f))
.map((f) => ({
name: f,
path: path.join(this.dataDir, f),
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
}))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
const migratedFiles = files.filter(f => migratedPattern.test(f))
.map(f => ({
const migratedFiles = files
.filter((f) => migratedPattern.test(f))
.map((f) => ({
name: f,
path: path.join(this.dataDir, f),
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
}))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// 保留最近3个备份文件
const backupsToDelete = backupFiles.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) {
databaseLogger.warn("Migration cleanup failed", {
operation: "migration_cleanup_error",
@@ -433,4 +380,4 @@ export class DatabaseMigration {
});
}
}
}
}

View File

@@ -1,31 +1,19 @@
import { databaseLogger } from "./logger.js";
/**
* Database Save Trigger - 自动触发内存数据库保存到磁盘
* 确保数据修改后能持久化保存
*/
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static saveTimeout: NodeJS.Timeout | null = null;
/**
* 初始化保存触发器
*/
static initialize(saveFunction: () => Promise<void>): void {
this.saveFunction = saveFunction;
this.isInitialized = true;
databaseLogger.info("Database save trigger initialized", {
operation: "db_save_trigger_init",
});
}
/**
* 触发数据库保存 - 防抖处理,避免频繁保存
*/
static async triggerSave(reason: string = "data_modification"): Promise<void> {
static async triggerSave(
reason: string = "data_modification",
): Promise<void> {
if (!this.isInitialized || !this.saveFunction) {
databaseLogger.warn("Database save trigger not initialized", {
operation: "db_save_trigger_not_init",
@@ -34,12 +22,10 @@ export class DatabaseSaveTrigger {
return;
}
// 清除之前的定时器
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
// 防抖延迟2秒执行如果2秒内有新的保存请求则重新计时
this.saveTimeout = setTimeout(async () => {
if (this.pendingSave) {
return;
@@ -58,22 +44,21 @@ export class DatabaseSaveTrigger {
} finally {
this.pendingSave = false;
}
}, 2000); // 2秒防抖
}, 2000);
}
/**
* 立即保存 - 用于关键操作
*/
static async forceSave(reason: string = "critical_operation"): Promise<void> {
if (!this.isInitialized || !this.saveFunction) {
databaseLogger.warn("Database save trigger not initialized for force save", {
operation: "db_save_trigger_force_not_init",
reason,
});
databaseLogger.warn(
"Database save trigger not initialized for force save",
{
operation: "db_save_trigger_force_not_init",
reason,
},
);
return;
}
// 清除防抖定时器
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
@@ -92,26 +77,18 @@ export class DatabaseSaveTrigger {
});
await this.saveFunction();
databaseLogger.success("Database force save completed", {
operation: "db_save_trigger_force_success",
reason,
});
} catch (error) {
databaseLogger.error("Database force save failed", error, {
operation: "db_save_trigger_force_failed",
reason,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error; // 重新抛出错误,因为这是强制保存
throw error;
} finally {
this.pendingSave = false;
}
}
/**
* 获取保存状态
*/
static getStatus(): {
initialized: boolean;
pendingSave: boolean;
@@ -124,9 +101,6 @@ export class DatabaseSaveTrigger {
};
}
/**
* 清理资源
*/
static cleanup(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
@@ -141,4 +115,4 @@ export class DatabaseSaveTrigger {
operation: "db_save_trigger_cleanup",
});
}
}
}

View File

@@ -5,40 +5,46 @@ interface EncryptedData {
iv: string;
tag: 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 {
private static readonly ALGORITHM = "aes-256-gcm";
private static readonly KEY_LENGTH = 32;
private static readonly IV_LENGTH = 16;
private static readonly SALT_LENGTH = 32;
// Fields requiring encryption - simple mapping, no complex logic
private static readonly ENCRYPTED_FIELDS = {
users: new Set(["password_hash", "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier"]),
users: new Set([
"password_hash",
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
]),
ssh_data: new Set(["password", "key", "keyPassword"]),
ssh_credentials: new Set(["password", "privateKey", "keyPassword", "key", "publicKey"]),
ssh_credentials: new Set([
"password",
"privateKey",
"keyPassword",
"key",
"publicKey",
]),
};
/**
* Encrypt field - no special cases
*/
static encryptField(plaintext: string, masterKey: Buffer, recordId: string, fieldName: string): string {
static encryptField(
plaintext: string,
masterKey: Buffer,
recordId: string,
fieldName: string,
): string {
if (!plaintext) return "";
const salt = crypto.randomBytes(this.SALT_LENGTH);
const context = `${recordId}:${fieldName}`;
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
const fieldKey = Buffer.from(
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
);
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
@@ -52,29 +58,38 @@ class FieldCrypto {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
salt: salt.toString("hex"),
recordId: recordId, // Store recordId for consistent decryption context
recordId: recordId,
};
return JSON.stringify(encryptedData);
}
/**
* Decrypt field - either succeeds or fails, no third option
*/
static decryptField(encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string): string {
static decryptField(
encryptedValue: string,
masterKey: Buffer,
recordId: string,
fieldName: string,
): string {
if (!encryptedValue) return "";
const encrypted: EncryptedData = JSON.parse(encryptedValue);
const salt = Buffer.from(encrypted.salt, "hex");
// Use ONLY the recordId that was stored during encryption
if (!encrypted.recordId) {
throw new Error(`Encrypted field missing recordId context - data corruption or legacy format not supported`);
throw new Error(
`Encrypted field missing recordId context - data corruption or legacy format not supported`,
);
}
const context = `${encrypted.recordId}:${fieldName}`;
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
const 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"));
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
@@ -83,13 +98,11 @@ class FieldCrypto {
return decrypted;
}
/**
* Check if field needs encryption - simple table lookup, no complex logic
*/
static shouldEncryptField(tableName: string, fieldName: string): boolean {
const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
const fields =
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
return fields ? fields.has(fieldName) : false;
}
}
export { FieldCrypto, type EncryptedData };
export { FieldCrypto, type EncryptedData };

View File

@@ -1,51 +1,47 @@
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
/**
* 延迟字段加密 - 处理从明文到加密的平滑迁移
* 用于在用户登录时将明文敏感数据逐步加密
*/
export class LazyFieldEncryption {
/**
* 检测字段是否为明文(未加密)
*/
static isPlaintextField(value: string): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
// 如果能解析为JSON且包含加密数据结构则认为已加密
if (parsed && typeof parsed === 'object' &&
parsed.data && parsed.iv && parsed.tag && parsed.salt && parsed.recordId) {
return false; // 已加密
if (
parsed &&
typeof parsed === "object" &&
parsed.data &&
parsed.iv &&
parsed.tag &&
parsed.salt &&
parsed.recordId
) {
return false;
}
// JSON格式但不是加密结构视为明文
return true;
} catch (jsonError) {
// 无法解析为JSON视为明文
return true;
}
}
/**
* 安全获取字段值 - 自动处理明文和加密数据
* 如果是明文,直接返回;如果已加密,则解密
*/
static safeGetFieldValue(
fieldValue: string,
userKEK: Buffer,
recordId: string,
fieldName: string
fieldName: string,
): string {
if (!fieldValue) return "";
if (this.isPlaintextField(fieldValue)) {
// 明文数据,直接返回
return fieldValue;
} else {
// 加密数据,需要解密
try {
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
const decrypted = FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
fieldName,
);
return decrypted;
} catch (error) {
databaseLogger.error("Failed to decrypt field", error, {
@@ -59,31 +55,24 @@ export class LazyFieldEncryption {
}
}
/**
* 迁移明文字段到加密状态
* 返回加密后的值,如果已经加密则返回原值
*/
static migrateFieldToEncrypted(
fieldValue: string,
userKEK: Buffer,
recordId: string,
fieldName: string
fieldName: string,
): { encrypted: string; wasPlaintext: boolean } {
if (!fieldValue) {
return { encrypted: "", wasPlaintext: false };
}
if (this.isPlaintextField(fieldValue)) {
// 明文数据,需要加密
try {
const encrypted = FieldCrypto.encryptField(fieldValue, userKEK, recordId, fieldName);
databaseLogger.info("Field migrated from plaintext to encrypted", {
operation: "lazy_encryption_migrate_success",
const encrypted = FieldCrypto.encryptField(
fieldValue,
userKEK,
recordId,
fieldName,
plaintextLength: fieldValue.length,
});
);
return { encrypted, wasPlaintext: true };
} catch (error) {
@@ -96,23 +85,19 @@ export class LazyFieldEncryption {
throw error;
}
} else {
// 已经加密,无需处理
return { encrypted: fieldValue, wasPlaintext: false };
}
}
/**
* 批量迁移记录中的敏感字段
*/
static migrateRecordSensitiveFields(
record: any,
sensitiveFields: string[],
userKEK: Buffer,
recordId: string
recordId: string,
): {
updatedRecord: any;
migratedFields: string[];
needsUpdate: boolean
needsUpdate: boolean;
} {
const updatedRecord = { ...record };
const migratedFields: string[] = [];
@@ -127,7 +112,7 @@ export class LazyFieldEncryption {
fieldValue,
userKEK,
recordId,
fieldName
fieldName,
);
updatedRecord[fieldName] = encrypted;
@@ -139,55 +124,48 @@ export class LazyFieldEncryption {
recordId,
fieldName,
});
// 不抛出错误,继续处理其他字段
}
}
}
if (needsUpdate) {
databaseLogger.info("Record requires sensitive field migration", {
operation: "lazy_encryption_record_migration_needed",
recordId,
migratedFields,
totalMigratedFields: migratedFields.length,
});
}
return { updatedRecord, migratedFields, needsUpdate };
}
/**
* 获取敏感字段列表 - 定义哪些字段需要延迟加密
*/
static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record<string, string[]> = {
'ssh_data': ['password', 'key', 'key_password'],
'ssh_credentials': ['password', 'key', 'key_password', 'private_key'],
'users': ['totp_secret', 'totp_backup_codes'],
ssh_data: ["password", "key", "key_password"],
ssh_credentials: ["password", "key", "key_password", "private_key"],
users: ["totp_secret", "totp_backup_codes"],
};
return sensitiveFieldsMap[tableName] || [];
}
/**
* 检查用户是否有需要迁移的明文数据
*/
static async checkUserNeedsMigration(
userId: string,
userKEK: Buffer,
db: any
db: any,
): Promise<{
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;
try {
// 检查 ssh_data 表
const sshHosts = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
const sshHosts = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
for (const host of sshHosts) {
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_data');
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
const hostPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
@@ -199,17 +177,19 @@ export class LazyFieldEncryption {
if (hostPlaintextFields.length > 0) {
plaintextFields.push({
table: 'ssh_data',
table: "ssh_data",
recordId: host.id.toString(),
fields: hostPlaintextFields,
});
}
}
// 检查 ssh_credentials
const sshCredentials = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
const sshCredentials = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
for (const credential of sshCredentials) {
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_credentials');
const sensitiveFields =
this.getSensitiveFieldsForTable("ssh_credentials");
const credentialPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
@@ -221,17 +201,16 @@ export class LazyFieldEncryption {
if (credentialPlaintextFields.length > 0) {
plaintextFields.push({
table: 'ssh_credentials',
table: "ssh_credentials",
recordId: credential.id.toString(),
fields: credentialPlaintextFields,
});
}
}
// 检查 users 表中的敏感字段
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
if (user) {
const sensitiveFields = this.getSensitiveFieldsForTable('users');
const sensitiveFields = this.getSensitiveFieldsForTable("users");
const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
@@ -243,23 +222,14 @@ export class LazyFieldEncryption {
if (userPlaintextFields.length > 0) {
plaintextFields.push({
table: 'users',
table: "users",
recordId: userId,
fields: userPlaintextFields,
});
}
}
databaseLogger.info("User migration check completed", {
operation: "lazy_encryption_user_check",
userId,
needsMigration,
plaintextFieldsCount: plaintextFields.length,
totalPlaintextFields: plaintextFields.reduce((sum, item) => sum + item.fields.length, 0),
});
return { needsMigration, plaintextFields };
} catch (error) {
databaseLogger.error("Failed to check user migration needs", error, {
operation: "lazy_encryption_user_check_failed",
@@ -270,4 +240,4 @@ export class LazyFieldEncryption {
return { needsMigration: false, plaintextFields: [] };
}
}
}
}

View File

@@ -14,23 +14,35 @@ export interface LogContext {
[key: string]: any;
}
// Sensitive fields that should be masked in logs
const SENSITIVE_FIELDS = [
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
"password",
"passphrase",
"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 {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
private logCounts = new Map<string, { count: number; lastLog: number }>();
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
private readonly RATE_LIMIT_WINDOW = 60000;
private readonly RATE_LIMIT_MAX = 10;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
@@ -44,27 +56,32 @@ class Logger {
private sanitizeContext(context: LogContext): LogContext {
const sanitized = { ...context };
// Mask sensitive fields
for (const field of SENSITIVE_FIELDS) {
if (sanitized[field] !== undefined) {
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
sanitized[field] = '[MASKED]';
} else if (typeof sanitized[field] === 'boolean') {
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
if (
typeof sanitized[field] === "string" &&
sanitized[field].length > 0
) {
sanitized[field] = "[MASKED]";
} else if (typeof sanitized[field] === "boolean") {
sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]";
} else {
sanitized[field] = '[MASKED]';
sanitized[field] = "[MASKED]";
}
}
}
// Truncate long fields
for (const field of TRUNCATE_FIELDS) {
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
sanitized[field] = sanitized[field].substring(0, 100) + '...';
if (
sanitized[field] &&
typeof sanitized[field] === "string" &&
sanitized[field].length > 100
) {
sanitized[field] = sanitized[field].substring(0, 100) + "...";
}
}
return sanitized;
}
@@ -82,13 +99,20 @@ class Logger {
if (context) {
const sanitizedContext = this.sanitizeContext(context);
const contextParts = [];
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
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 (sanitizedContext.operation)
contextParts.push(`op:${sanitizedContext.operation}`);
if (sanitizedContext.userId)
contextParts.push(`user:${sanitizedContext.userId}`);
if (sanitizedContext.hostId)
contextParts.push(`host:${sanitizedContext.hostId}`);
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) {
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
@@ -119,27 +143,25 @@ class Logger {
if (level === "debug" && process.env.NODE_ENV === "production") {
return false;
}
// Rate limiting for frequent messages
const now = Date.now();
const logKey = `${level}:${message}`;
const logInfo = this.logCounts.get(logKey);
if (logInfo) {
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
logInfo.count++;
if (logInfo.count > this.RATE_LIMIT_MAX) {
return false; // Rate limited
return false;
}
} else {
// Reset counter for new window
logInfo.count = 1;
logInfo.lastLog = now;
}
} else {
this.logCounts.set(logKey, { count: 1, lastLog: now });
}
return true;
}

View File

@@ -1,131 +1,94 @@
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = "users" | "ssh_data" | "ssh_credentials";
/**
* SimpleDBOps - Simplified encrypted database operations
*
* Linus-style simplification:
* - Remove all complex abstraction layers
* - Direct CRUD operations
* - Automatic encryption/decryption
* - No special case handling
*/
class SimpleDBOps {
/**
* Insert encrypted record
*/
static async insert<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
data: T,
userId: string,
): Promise<T> {
// Get user data key once and reuse throughout operation
const userDataKey = DataCrypto.validateUserAccess(userId);
// Generate consistent temporary ID for encryption context if record has no ID
const tempId = data.id || `temp-${userId}-${Date.now()}`;
const dataWithTempId = { ...data, id: tempId };
// Encrypt data using the locked key - recordId will be stored in encrypted fields
const encryptedData = DataCrypto.encryptRecord(tableName, dataWithTempId, userId, userDataKey);
const encryptedData = DataCrypto.encryptRecord(
tableName,
dataWithTempId,
userId,
userDataKey,
);
// Remove temp ID if it was generated, let database assign real ID
if (!data.id) {
delete encryptedData.id;
}
// Insert into database
const result = await getDb().insert(table).values(encryptedData).returning();
const result = await getDb()
.insert(table)
.values(encryptedData)
.returning();
// Trigger database save after insert
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
// Decrypt return result using the same key - FieldCrypto will use stored recordId
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result[0],
userId,
userDataKey
userDataKey,
);
return decryptedResult as T;
}
/**
* Query multiple records
*/
static async select<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T[]> {
// Check if user data is unlocked - return empty array if locked
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.debug("User data locked - returning empty results", {
operation: "select_data_locked",
userId,
tableName
});
return [];
}
// Execute query
const results = await query;
// Decrypt results using locked key
const decryptedResults = DataCrypto.decryptRecords(
tableName,
results,
userId,
userDataKey
userDataKey,
);
return decryptedResults;
}
/**
* Query single record
*/
static async selectOne<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T | undefined> {
// Check if user data is unlocked - return undefined if locked
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.debug("User data locked - returning undefined", {
operation: "selectOne_data_locked",
userId,
tableName
});
return undefined;
}
// Execute query
const result = await query;
if (!result) return undefined;
// Decrypt results using locked key
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result,
userId,
userDataKey
userDataKey,
);
return decryptedResult;
}
/**
* Update record
*/
static async update<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
@@ -133,36 +96,33 @@ class SimpleDBOps {
data: Partial<T>,
userId: string,
): Promise<T[]> {
// Get user data key once and reuse throughout operation
const userDataKey = DataCrypto.validateUserAccess(userId);
// Encrypt update data using the locked key
const encryptedData = DataCrypto.encryptRecord(tableName, data, userId, userDataKey);
const encryptedData = DataCrypto.encryptRecord(
tableName,
data,
userId,
userDataKey,
);
// Execute update
const result = await getDb()
.update(table)
.set(encryptedData)
.where(where)
.returning();
// Trigger database save after update
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
// Decrypt return data using the same key
const decryptedResults = DataCrypto.decryptRecords(
tableName,
result,
userId,
userDataKey
userDataKey,
);
return decryptedResults as T[];
}
/**
* Delete record
*/
static async delete(
table: SQLiteTable<any>,
tableName: TableName,
@@ -171,36 +131,27 @@ class SimpleDBOps {
): Promise<any[]> {
const result = await getDb().delete(table).where(where).returning();
// Trigger database save after delete
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
return result;
}
/**
* Health check
*/
static async healthCheck(userId: string): Promise<boolean> {
return DataCrypto.canUserAccessData(userId);
}
/**
* Check if user data is unlocked
*/
static isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
/**
* Special method: return encrypted data (for auto-start scenarios)
* No decryption, return data in encrypted state directly
*/
static async selectEncrypted(query: any, tableName: TableName): Promise<any[]> {
// Execute query directly, no decryption
static async selectEncrypted(
query: any,
tableName: TableName,
): Promise<any[]> {
const results = await query;
return results;
}
}
export { SimpleDBOps, type TableName };
export { SimpleDBOps, type TableName };

View File

@@ -1,14 +1,10 @@
// Import SSH2 using ES modules
import ssh2Pkg from "ssh2";
const ssh2Utils = ssh2Pkg.utils;
// Simple fallback SSH key type detection
function detectKeyTypeFromContent(keyContent: string): string {
const content = keyContent.trim();
// Check for OpenSSH format headers
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
// Look for key type indicators in the content
if (
content.includes("ssh-ed25519") ||
content.includes("AAAAC3NzaC1lZDI1NTE5")
@@ -28,14 +24,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
return "ecdsa-sha2-nistp521";
}
// For OpenSSH format, try to detect by analyzing the base64 content structure
try {
const base64Content = content
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
.replace("-----END OPENSSH PRIVATE KEY-----", "")
.replace(/\s/g, "");
// OpenSSH format starts with "openssh-key-v1" followed by key type
const decoded = Buffer.from(base64Content, "base64").toString("binary");
if (decoded.includes("ssh-rsa")) {
@@ -54,15 +48,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
return "ecdsa-sha2-nistp521";
}
// Default to RSA for OpenSSH format if we can't detect specifically
return "ssh-rsa";
} catch (error) {
// If decoding fails, default to RSA as it's most common for OpenSSH format
return "ssh-rsa";
}
}
// Check for traditional PEM headers
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
return "ssh-rsa";
}
@@ -70,12 +61,10 @@ function detectKeyTypeFromContent(keyContent: string): string {
return "ssh-dss";
}
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-----")) {
// Try to decode and analyze the DER structure for better detection
try {
const base64Content = content
.replace("-----BEGIN PRIVATE KEY-----", "")
@@ -85,35 +74,23 @@ function detectKeyTypeFromContent(keyContent: string): string {
const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString("binary");
// Check for algorithm identifiers in the DER structure
if (decodedString.includes("1.2.840.113549.1.1.1")) {
// RSA OID
return "ssh-rsa";
} else if (decodedString.includes("1.2.840.10045.2.1")) {
// EC Private Key OID - this indicates ECDSA
if (decodedString.includes("1.2.840.10045.3.1.7")) {
// prime256v1 curve OID
return "ecdsa-sha2-nistp256";
}
return "ecdsa-sha2-nistp256"; // Default to P-256
return "ecdsa-sha2-nistp256";
} else if (decodedString.includes("1.3.101.112")) {
// Ed25519 OID
return "ssh-ed25519";
}
} catch (error) {
// If decoding fails, fall back to length-based detection
}
} catch (error) {}
// Fallback: Try to detect key type from the content structure
// This is a fallback for PKCS#8 format keys
if (content.length < 800) {
// Ed25519 keys are typically shorter
return "ssh-ed25519";
} else if (content.length > 1600) {
// RSA keys are typically longer
return "ssh-rsa";
} else {
// ECDSA keys are typically medium length
return "ecdsa-sha2-nistp256";
}
}
@@ -121,11 +98,9 @@ function detectKeyTypeFromContent(keyContent: string): string {
return "unknown";
}
// Detect public key type from public key content
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim();
// SSH public keys start with the key type
if (content.startsWith("ssh-rsa ")) {
return "ssh-rsa";
}
@@ -145,9 +120,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
return "ssh-dss";
}
// Check for PEM format public keys
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
// Try to decode the base64 content to detect key type
try {
const base64Content = content
.replace("-----BEGIN PUBLIC KEY-----", "")
@@ -157,26 +130,18 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const decoded = Buffer.from(base64Content, "base64");
const decodedString = decoded.toString("binary");
// Check for algorithm identifiers in the DER structure
if (decodedString.includes("1.2.840.113549.1.1.1")) {
// RSA OID
return "ssh-rsa";
} else if (decodedString.includes("1.2.840.10045.2.1")) {
// EC Public Key OID - this indicates ECDSA
if (decodedString.includes("1.2.840.10045.3.1.7")) {
// prime256v1 curve OID
return "ecdsa-sha2-nistp256";
}
return "ecdsa-sha2-nistp256"; // Default to P-256
return "ecdsa-sha2-nistp256";
} else if (decodedString.includes("1.3.101.112")) {
// Ed25519 OID
return "ssh-ed25519";
}
} catch (error) {
// If decoding fails, fall back to length-based detection
}
} catch (error) {}
// Fallback: Try to guess based on key length
if (content.length < 400) {
return "ssh-ed25519";
} else if (content.length > 600) {
@@ -190,7 +155,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
return "ssh-rsa";
}
// Check for base64 encoded key data patterns
if (content.includes("AAAAB3NzaC1yc2E")) {
return "ssh-rsa";
}
@@ -236,9 +200,6 @@ export interface KeyPairValidationResult {
error?: string;
}
/**
* Parse SSH private key and extract public key and type information
*/
export function parseSSHKey(
privateKeyData: string,
passphrase?: string,
@@ -248,28 +209,21 @@ export function parseSSHKey(
let publicKey = "";
let useSSH2 = false;
// Try SSH2 first if available
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
try {
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
if (!(parsedKey instanceof Error)) {
// Extract key type
if (parsedKey.type) {
keyType = parsedKey.type;
}
// Generate public key in SSH format
try {
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)) {
// Convert binary SSH data to base64 and create proper SSH key format
const base64Data = publicKeyBuffer.toString("base64");
// Create proper SSH public key format: "keytype base64data"
if (keyType === "ssh-rsa") {
publicKey = `ssh-rsa ${base64Data}`;
} else if (keyType === "ssh-ed25519") {
@@ -288,16 +242,12 @@ export function parseSSHKey(
useSSH2 = true;
}
} catch (error) {
// SSH2 parsing failed, will fall back to content detection
}
} catch (error) {}
}
// Fallback to content-based detection
if (!useSSH2) {
keyType = detectKeyTypeFromContent(privateKeyData);
// For fallback, we can't generate public key but the detection is still useful
publicKey = "";
}
@@ -308,7 +258,6 @@ export function parseSSHKey(
success: keyType !== "unknown",
};
} catch (error) {
// Final fallback - try content detection
try {
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
if (fallbackKeyType !== "unknown") {
@@ -319,9 +268,7 @@ export function parseSSHKey(
success: true,
};
}
} catch (fallbackError) {
// Even fallback detection failed
}
} catch (fallbackError) {}
return {
privateKey: privateKeyData,
@@ -334,9 +281,6 @@ export function parseSSHKey(
}
}
/**
* Parse SSH public key and extract type information
*/
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
try {
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 {
try {
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 {
const keyTypeMap: Record<string, string> = {
"ssh-rsa": "RSA",
@@ -393,16 +331,12 @@ export function getFriendlyKeyTypeName(keyType: string): string {
return keyTypeMap[keyType] || keyType;
}
/**
* Validate if a private key and public key form a valid key pair
*/
export function validateKeyPair(
privateKeyData: string,
publicKeyData: string,
passphrase?: string,
): KeyPairValidationResult {
try {
// First parse the private key and try to generate public key
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
const publicKeyInfo = parsePublicKey(publicKeyData);
@@ -424,7 +358,6 @@ export function validateKeyPair(
};
}
// Check if key types match
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
return {
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()) {
const generatedPublicKey = privateKeyInfo.publicKey.trim();
const providedPublicKey = publicKeyData.trim();
// Compare the key data part (excluding comments)
const generatedKeyParts = generatedPublicKey.split(" ");
const providedKeyParts = providedPublicKey.split(" ");
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
// Compare key type and key data (first two parts)
const generatedKeyData =
generatedKeyParts[0] + " " + generatedKeyParts[1];
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
@@ -468,9 +398,8 @@ export function validateKeyPair(
}
}
// If we can't generate public key or compare, just check if types match
return {
isValid: true, // Assume valid if types match and no errors
isValid: true,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: "Unable to verify key pair match, but key types are compatible",

View File

@@ -3,22 +3,12 @@ import { promises as fs } from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
/**
* SystemCrypto - Open source friendly system key management
*
* Linus principles:
* - Remove complex "system master key" layer - doesn't solve real threats
* - Remove hardcoded default keys - security disaster for open source software
* - Auto-generate on first startup - each instance independently secure
* - Simple and direct, focus on real security boundaries
*/
class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null;
private internalAuthToken: string | null = null;
private constructor() {}
static getInstance(): SystemCrypto {
@@ -28,25 +18,15 @@ class SystemCrypto {
return this.instance;
}
/**
* Initialize JWT secret - environment variable only
*/
async initializeJWTSecret(): Promise<void> {
try {
databaseLogger.info("Initializing JWT secret", {
operation: "jwt_init",
});
// Check environment variable
const envSecret = process.env.JWT_SECRET;
if (envSecret && envSecret.length >= 64) {
this.jwtSecret = envSecret;
return;
}
// No environment variable - generate and guide user
await this.generateAndGuideUser();
} catch (error) {
databaseLogger.error("Failed to initialize JWT secret", error, {
operation: "jwt_init_failed",
@@ -55,9 +35,6 @@ class SystemCrypto {
}
}
/**
* Get JWT secret
*/
async getJWTSecret(): Promise<string> {
if (!this.jwtSecret) {
await this.initializeJWTSecret();
@@ -65,36 +42,15 @@ class SystemCrypto {
return this.jwtSecret!;
}
/**
* Initialize database encryption key - environment variable only
*/
async initializeDatabaseKey(): Promise<void> {
try {
databaseLogger.info("Initializing database encryption key", {
operation: "db_key_init",
});
// Check environment variable
const envKey = process.env.DATABASE_KEY;
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) {
this.databaseKey = Buffer.from(envKey, 'hex');
databaseLogger.info("Using existing DATABASE_KEY from environment", {
operation: "db_key_use_existing",
keyLength: envKey.length
});
this.databaseKey = Buffer.from(envKey, "hex");
return;
}
// No environment variable - generate and guide user
await this.generateAndGuideDatabaseKey();
} catch (error) {
databaseLogger.error("Failed to initialize database key", error, {
operation: "db_key_init_failed",
@@ -103,9 +59,6 @@ class SystemCrypto {
}
}
/**
* Get database encryption key
*/
async getDatabaseKey(): Promise<Buffer> {
if (!this.databaseKey) {
await this.initializeDatabaseKey();
@@ -113,25 +66,15 @@ class SystemCrypto {
return this.databaseKey!;
}
/**
* Initialize internal auth token - environment variable only
*/
async initializeInternalAuthToken(): Promise<void> {
try {
databaseLogger.info("Initializing internal auth token", {
operation: "internal_auth_init",
});
// Check environment variable
const envToken = process.env.INTERNAL_AUTH_TOKEN;
if (envToken && envToken.length >= 32) {
this.internalAuthToken = envToken;
return;
}
// No environment variable - generate and guide user
await this.generateAndGuideInternalAuthToken();
} catch (error) {
databaseLogger.error("Failed to initialize internal auth token", error, {
operation: "internal_auth_init_failed",
@@ -140,9 +83,6 @@ class SystemCrypto {
}
}
/**
* Get internal auth token
*/
async getInternalAuthToken(): Promise<string> {
if (!this.internalAuthToken) {
await this.initializeInternalAuthToken();
@@ -150,79 +90,58 @@ class SystemCrypto {
return this.internalAuthToken!;
}
/**
* Generate and auto-save to .env file
*/
private async generateAndGuideUser(): Promise<void> {
const newSecret = crypto.randomBytes(32).toString('hex');
const instanceId = crypto.randomBytes(8).toString('hex');
const newSecret = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
// Set in memory for current session
this.jwtSecret = newSecret;
// Auto-save to .env file
await this.updateEnvFile("JWT_SECRET", newSecret);
databaseLogger.success("JWT secret auto-generated and saved to .env", {
operation: "jwt_auto_generated",
instanceId,
envVarName: "JWT_SECRET",
note: "Ready for use - no restart required"
note: "Ready for use - no restart required",
});
}
// ===== Database key generation and storage methods =====
/**
* Generate and auto-save database key to .env file
*/
private async generateAndGuideDatabaseKey(): Promise<void> {
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
const newKeyHex = newKey.toString('hex');
const instanceId = crypto.randomBytes(8).toString('hex');
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
// Set in memory for current session
this.databaseKey = newKey;
// Auto-save to .env file
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
databaseLogger.success("🔒 Database key auto-generated and saved to .env", {
databaseLogger.success("Database key auto-generated and saved to .env", {
operation: "db_key_auto_generated",
instanceId,
envVarName: "DATABASE_KEY",
note: "Ready for use - no restart required"
note: "Ready for use - no restart required",
});
}
/**
* Generate and auto-save internal auth token to .env file
*/
private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString('hex'); // 256-bit token for security
const instanceId = crypto.randomBytes(8).toString('hex');
const newToken = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
// Set in memory for current session
this.internalAuthToken = newToken;
// Auto-save to .env file
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
operation: "internal_auth_auto_generated",
instanceId,
envVarName: "INTERNAL_AUTH_TOKEN",
note: "Ready for use - no restart required"
});
databaseLogger.success(
"Internal auth token auto-generated and saved to .env",
{
operation: "internal_auth_auto_generated",
instanceId,
envVarName: "INTERNAL_AUTH_TOKEN",
note: "Ready for use - no restart required",
},
);
}
/**
* Validate JWT secret system
*/
async validateJWTSecret(): Promise<boolean> {
try {
const secret = await this.getJWTSecret();
@@ -230,7 +149,6 @@ class SystemCrypto {
return false;
}
// Test JWT operations
const jwt = await import("jsonwebtoken");
const testPayload = { test: true, timestamp: Date.now() };
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
@@ -245,85 +163,62 @@ class SystemCrypto {
}
}
/**
* Get JWT key status (simplified version)
*/
async getSystemKeyStatus() {
const isValid = await this.validateJWTSecret();
const hasSecret = this.jwtSecret !== null;
// Check environment variable
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
const hasEnvVar = !!(
process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64
);
return {
hasSecret,
isValid,
storage: {
environment: hasEnvVar
environment: hasEnvVar,
},
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> {
// 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 envPath = path.join(dataDir, ".env");
try {
// Ensure data directory exists
await fs.mkdir(dataDir, { recursive: true });
let envContent = "";
// Read existing .env file if it exists
try {
envContent = await fs.readFile(envPath, "utf8");
} catch {
// File doesn't exist, will create new one
envContent = "# Termix Auto-generated Configuration\n\n";
}
// Check if key already exists
const keyRegex = new RegExp(`^${key}=.*$`, "m");
if (keyRegex.test(envContent)) {
// Update existing key
envContent = envContent.replace(keyRegex, `${key}=${value}`);
} else {
// Add new key
if (!envContent.includes("# Security Keys")) {
envContent += "\n# Security Keys (Auto-generated)\n";
}
envContent += `${key}=${value}\n`;
}
// Write updated content
await fs.writeFile(envPath, envContent);
// Update process.env for current session
process.env[key] = value;
databaseLogger.info(`Environment variable ${key} updated in .env file`, {
operation: "env_file_update",
key,
path: envPath
});
} catch (error) {
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
operation: "env_file_update_failed",
key
key,
});
throw error;
}
}
}
export { SystemCrypto };
export { SystemCrypto };

View File

@@ -20,37 +20,29 @@ interface EncryptedDEK {
}
interface UserSession {
dataKey: Buffer; // Store DEK directly, delete just-in-time fantasy
dataKey: Buffer;
lastActivity: 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 {
private static instance: UserCrypto;
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 KEK_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 MAX_INACTIVITY = 6 * 60 * 60 * 1000; // 6 hours, reasonable inactivity timeout
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
private constructor() {
// Reasonable cleanup interval
setInterval(() => {
this.cleanupExpiredSessions();
}, 5 * 60 * 1000); // Clean every 5 minutes, not 30 seconds
setInterval(
() => {
this.cleanupExpiredSessions();
},
5 * 60 * 1000,
);
}
static getInstance(): UserCrypto {
@@ -60,16 +52,10 @@ class UserCrypto {
return this.instance;
}
/**
* Set callback for session expiration (used by AuthManager)
*/
setSessionExpiredCallback(callback: (userId: string) => void): void {
this.sessionExpiredCallback = callback;
}
/**
* User registration: generate KEK salt and DEK
*/
async setupUserEncryption(userId: string, password: string): Promise<void> {
const kekSalt = await this.generateKEKSalt();
await this.storeKEKSalt(userId, kekSalt);
@@ -79,23 +65,12 @@ class UserCrypto {
const encryptedDEK = this.encryptDEK(DEK, KEK);
await this.storeEncryptedDEK(userId, encryptedDEK);
// Immediately clean temporary keys
KEK.fill(0);
DEK.fill(0);
databaseLogger.success("User encryption setup completed", {
operation: "user_crypto_setup",
userId,
});
}
/**
* User authentication: validate password and cache DEK
* Deleted just-in-time fantasy, works directly
*/
async authenticateUser(userId: string, password: string): Promise<boolean> {
try {
// Validate password and decrypt DEK
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
@@ -107,40 +82,31 @@ class UserCrypto {
}
const DEK = this.decryptDEK(encryptedDEK, KEK);
KEK.fill(0); // Immediately clean KEK
KEK.fill(0);
// Debug: Check DEK validity
if (!DEK || DEK.length === 0) {
databaseLogger.error("DEK is empty or invalid after decryption", {
operation: "user_crypto_auth_debug",
userId,
dekLength: DEK ? DEK.length : 0
dekLength: DEK ? DEK.length : 0,
});
return false;
}
// Create user session, cache DEK directly
const now = Date.now();
// Clean old session
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK), // Create proper Buffer copy
dataKey: Buffer.from(DEK),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0); // Clean temporary DEK
databaseLogger.success("User authenticated and DEK cached", {
operation: "user_crypto_auth",
userId,
duration: UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
return true;
} 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 {
const session = this.userSessions.get(userId);
if (!session) {
@@ -165,74 +127,49 @@ class UserCrypto {
const now = Date.now();
// Check if session has expired
if (now > session.expiresAt) {
this.userSessions.delete(userId);
session.dataKey.fill(0);
databaseLogger.info("User session expired", {
operation: "user_session_expired",
userId,
});
// Trigger callback to invalidate JWT tokens
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null;
}
// Check if max inactivity time exceeded
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
this.userSessions.delete(userId);
session.dataKey.fill(0);
databaseLogger.info("User session inactive timeout", {
operation: "user_session_inactive",
userId,
});
// Trigger callback to invalidate JWT tokens
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null;
}
// Update last activity time
session.lastActivity = now;
return session.dataKey;
}
/**
* User logout: clear session
*/
logoutUser(userId: string): void {
const session = this.userSessions.get(userId);
if (session) {
session.dataKey.fill(0); // Securely clear key
session.dataKey.fill(0);
this.userSessions.delete(userId);
}
databaseLogger.info("User logged out", {
operation: "user_crypto_logout",
userId,
});
}
/**
* Check if user is unlocked
*/
isUserUnlocked(userId: string): boolean {
return this.getUserDataKey(userId) !== null;
}
/**
* Change user password
*/
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
async changeUserPassword(
userId: string,
oldPassword: string,
newPassword: string,
): Promise<boolean> {
try {
// Validate old password
const isValid = await this.validatePassword(userId, oldPassword);
if (!isValid) return false;
// Get current DEK
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
@@ -242,21 +179,17 @@ class UserCrypto {
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
// Generate new KEK salt and encrypt DEK
const newKekSalt = await this.generateKEKSalt();
const newKEK = this.deriveKEK(newPassword, newKekSalt);
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
// Store new salt and encrypted DEK
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
// Clean all temporary keys
oldKEK.fill(0);
newKEK.fill(0);
DEK.fill(0);
// Clean user session, require re-login
this.logoutUser(userId);
return true;
@@ -265,9 +198,10 @@ class UserCrypto {
}
}
// ===== Private methods =====
private async validatePassword(userId: string, password: string): Promise<boolean> {
private async validatePassword(
userId: string,
password: string,
): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) return false;
@@ -278,7 +212,6 @@ class UserCrypto {
const DEK = this.decryptDEK(encryptedDEK, KEK);
// Clean temporary keys
KEK.fill(0);
DEK.fill(0);
@@ -293,26 +226,20 @@ class UserCrypto {
const expiredUsers: string[] = [];
for (const [userId, session] of this.userSessions.entries()) {
if (now > session.expiresAt || now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
session.dataKey.fill(0); // Securely clear key
if (
now > session.expiresAt ||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
) {
session.dataKey.fill(0);
expiredUsers.push(userId);
}
}
expiredUsers.forEach(userId => {
expiredUsers.forEach((userId) => {
this.userSessions.delete(userId);
});
if (expiredUsers.length > 0) {
databaseLogger.info(`Cleaned up ${expiredUsers.length} expired sessions`, {
operation: "session_cleanup",
count: expiredUsers.length,
});
}
}
// ===== Database operations and encryption methods (simplified version) =====
private async generateKEKSalt(): Promise<KEKSalt> {
return {
salt: crypto.randomBytes(32).toString("hex"),
@@ -328,7 +255,7 @@ class UserCrypto {
Buffer.from(kekSalt.salt, "hex"),
kekSalt.iterations,
UserCrypto.KEK_LENGTH,
"sha256"
"sha256",
);
}
@@ -353,7 +280,7 @@ class UserCrypto {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
kek,
Buffer.from(encryptedDEK.iv, "hex")
Buffer.from(encryptedDEK.iv, "hex"),
);
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
@@ -363,15 +290,20 @@ class UserCrypto {
return decrypted;
}
// Database operation methods
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
const key = `user_kek_salt_${userId}`;
const value = JSON.stringify(kekSalt);
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
@@ -380,7 +312,10 @@ class UserCrypto {
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
try {
const key = `user_kek_salt_${userId}`;
const result = await getDb().select().from(settings).where(eq(settings.key, key));
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (result.length === 0) {
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 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) {
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
@@ -408,7 +352,10 @@ class UserCrypto {
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
try {
const key = `user_encrypted_dek_${userId}`;
const result = await getDb().select().from(settings).where(eq(settings.key, key));
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (result.length === 0) {
return null;
@@ -419,7 +366,6 @@ class UserCrypto {
return null;
}
}
}
export { UserCrypto, type KEKSalt, type EncryptedDEK };
export { UserCrypto, type KEKSalt, type EncryptedDEK };

View File

@@ -1,9 +1,16 @@
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 { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import crypto from "crypto";
interface UserExportData {
version: string;
@@ -23,87 +30,98 @@ interface UserExportData {
metadata: {
totalRecords: number;
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 {
private static readonly EXPORT_VERSION = "v2.0";
/**
* Export user data
*/
static async exportUserData(
userId: string,
options: {
format?: 'encrypted' | 'plaintext';
scope?: 'user_data' | 'all';
format?: "encrypted" | "plaintext";
scope?: "user_data" | "all";
includeCredentials?: boolean;
} = {}
} = {},
): Promise<UserExportData> {
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
const {
format = "encrypted",
scope = "user_data",
includeCredentials = true,
} = options;
try {
databaseLogger.info("Starting user data export", {
operation: "user_data_export",
userId,
format,
scope,
includeCredentials,
});
// Verify user exists
const user = await getDb().select().from(users).where(eq(users.id, userId));
const user = await getDb()
.select()
.from(users)
.where(eq(users.id, userId));
if (!user || user.length === 0) {
throw new Error(`User not found: ${userId}`);
}
const userRecord = user[0];
// Get user data key (if decryption needed)
let userDataKey: Buffer | null = null;
if (format === 'plaintext') {
if (format === "plaintext") {
userDataKey = DataCrypto.getUserDataKey(userId);
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().select().from(sshData).where(eq(sshData.userId, userId));
const processedSshHosts = format === 'plaintext' && userDataKey
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
: sshHosts;
const sshHosts = await getDb()
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const processedSshHosts =
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
)
: sshHosts;
// Export SSH credentials (if included)
let sshCredentialsData: any[] = [];
if (includeCredentials) {
const credentials = await getDb().select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
sshCredentialsData = format === 'plaintext' && userDataKey
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
: credentials;
const credentials = await getDb()
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
sshCredentialsData =
format === "plaintext" && userDataKey
? credentials.map((cred) =>
DataCrypto.decryptRecord(
"ssh_credentials",
cred,
userId,
userDataKey!,
),
)
: credentials;
}
// Export file manager data
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
getDb().select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
getDb().select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
getDb().select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
getDb()
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId)),
getDb()
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId)),
getDb()
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)),
]);
// Export dismissed alerts
const alerts = await getDb().select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
const alerts = await getDb()
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
// Build export data
const exportData: UserExportData = {
version: this.EXPORT_VERSION,
exportedAt: new Date().toISOString(),
@@ -120,8 +138,14 @@ class UserDataExport {
dismissedAlerts: alerts,
},
metadata: {
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
encrypted: format === 'encrypted',
totalRecords:
processedSshHosts.length +
sshCredentialsData.length +
recentFiles.length +
pinnedFiles.length +
shortcuts.length +
alerts.length,
encrypted: format === "encrypted",
exportType: scope,
},
};
@@ -147,30 +171,24 @@ class UserDataExport {
}
}
/**
* Export as JSON string
*/
static async exportUserDataToJSON(
userId: string,
options: {
format?: 'encrypted' | 'plaintext';
scope?: 'user_data' | 'all';
format?: "encrypted" | "plaintext";
scope?: "user_data" | "all";
includeCredentials?: boolean;
pretty?: boolean;
} = {}
} = {},
): Promise<string> {
const { pretty = true } = options;
const exportData = await this.exportUserData(userId, options);
return JSON.stringify(exportData, null, pretty ? 2 : 0);
}
/**
* Validate export data format
*/
static validateExportData(data: any): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!data || typeof data !== 'object') {
if (!data || typeof data !== "object") {
errors.push("Export data must be an object");
return { valid: false, errors };
}
@@ -183,28 +201,43 @@ class UserDataExport {
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");
}
if (!data.metadata || typeof data.metadata !== 'object') {
if (!data.metadata || typeof data.metadata !== "object") {
errors.push("Missing or invalid metadata field");
}
// Check required data fields
if (data.userData) {
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
const requiredFields = [
"sshHosts",
"sshCredentials",
"fileManagerData",
"dismissedAlerts",
];
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`);
}
}
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
const fmFields = ['recent', 'pinned', 'shortcuts'];
if (
data.userData.fileManagerData &&
typeof data.userData.fileManagerData === "object"
) {
const fmFields = ["recent", "pinned", "shortcuts"];
for (const field of fmFields) {
if (!Array.isArray(data.userData.fileManagerData[field])) {
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
errors.push(
`Missing or invalid userData.fileManagerData.${field} field`,
);
}
}
}
@@ -213,9 +246,6 @@ class UserDataExport {
return { valid: errors.length === 0, errors };
}
/**
* Get export data statistics
*/
static getExportStats(data: UserExportData): {
version: string;
exportedAt: string;
@@ -237,9 +267,10 @@ class UserDataExport {
breakdown: {
sshHosts: data.userData.sshHosts.length,
sshCredentials: data.userData.sshCredentials.length,
fileManagerItems: data.userData.fileManagerData.recent.length +
data.userData.fileManagerData.pinned.length +
data.userData.fileManagerData.shortcuts.length,
fileManagerItems:
data.userData.fileManagerData.recent.length +
data.userData.fileManagerData.pinned.length +
data.userData.fileManagerData.shortcuts.length,
dismissedAlerts: data.userData.dismissedAlerts.length,
},
encrypted: data.metadata.encrypted,
@@ -247,4 +278,4 @@ class UserDataExport {
}
}
export { UserDataExport, type UserExportData };
export { UserDataExport, type UserExportData };

View File

@@ -1,5 +1,13 @@
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 { DataCrypto } from "./data-crypto.js";
import { UserDataExport, type UserExportData } from "./user-data-export.js";
@@ -26,62 +34,40 @@ interface ImportResult {
dryRun: boolean;
}
/**
* UserDataImport - User data import
*
* Linus principles:
* - Import should not break existing data (unless explicitly requested)
* - Support dry-run mode for validation
* - Simple strategy for ID conflicts: regenerate
* - Error handling must be explicit, no silent failures
*/
class UserDataImport {
/**
* Import user data
*/
static async importUserData(
targetUserId: string,
exportData: UserExportData,
options: ImportOptions = {}
options: ImportOptions = {},
): Promise<ImportResult> {
const {
replaceExisting = false,
skipCredentials = false,
skipFileManagerData = false,
dryRun = false
dryRun = false,
} = options;
try {
databaseLogger.info("Starting user data import", {
operation: "user_data_import",
targetUserId,
sourceUserId: exportData.userId,
sourceUsername: exportData.username,
dryRun,
replaceExisting,
skipCredentials,
skipFileManagerData,
});
// Verify target user exists
const targetUser = await getDb().select().from(users).where(eq(users.id, targetUserId));
const targetUser = await getDb()
.select()
.from(users)
.where(eq(users.id, targetUserId));
if (!targetUser || targetUser.length === 0) {
throw new Error(`Target user not found: ${targetUserId}`);
}
// Validate export data format
const validation = UserDataExport.validateExportData(exportData);
if (!validation.valid) {
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
throw new Error(`Invalid export data: ${validation.errors.join(", ")}`);
}
// Verify user data is unlocked (if data is encrypted)
let userDataKey: Buffer | null = null;
if (exportData.metadata.encrypted) {
userDataKey = DataCrypto.getUserDataKey(targetUserId);
if (!userDataKey) {
throw new Error("Target user data not unlocked - password required for encrypted import");
throw new Error(
"Target user data not unlocked - password required for encrypted import",
);
}
}
@@ -98,48 +84,54 @@ class UserDataImport {
dryRun,
};
// Import SSH host configurations
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
if (
exportData.userData.sshHosts &&
exportData.userData.sshHosts.length > 0
) {
const importStats = await this.importSshHosts(
targetUserId,
exportData.userData.sshHosts,
{ replaceExisting, dryRun, userDataKey }
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshHostsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// Import SSH credentials
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
if (
!skipCredentials &&
exportData.userData.sshCredentials &&
exportData.userData.sshCredentials.length > 0
) {
const importStats = await this.importSshCredentials(
targetUserId,
exportData.userData.sshCredentials,
{ replaceExisting, dryRun, userDataKey }
{ replaceExisting, dryRun, userDataKey },
);
result.summary.sshCredentialsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// Import file manager data
if (!skipFileManagerData && exportData.userData.fileManagerData) {
const importStats = await this.importFileManagerData(
targetUserId,
exportData.userData.fileManagerData,
{ replaceExisting, dryRun }
{ replaceExisting, dryRun },
);
result.summary.fileManagerItemsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// Import dismissed alerts
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
if (
exportData.userData.dismissedAlerts &&
exportData.userData.dismissedAlerts.length > 0
) {
const importStats = await this.importDismissedAlerts(
targetUserId,
exportData.userData.dismissedAlerts,
{ replaceExisting, dryRun }
{ replaceExisting, dryRun },
);
result.summary.dismissedAlertsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
@@ -166,13 +158,14 @@ class UserDataImport {
}
}
/**
* Import SSH host configurations
*/
private static async importSshHosts(
targetUserId: string,
sshHosts: any[],
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
options: {
replaceExisting: boolean;
dryRun: boolean;
userDataKey: Buffer | null;
},
) {
let imported = 0;
let skipped = 0;
@@ -185,29 +178,33 @@ class UserDataImport {
continue;
}
// Generate temporary ID for encryption context, then remove for database insert
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
const newHostData = {
...host,
id: tempId, // Temporary ID for encryption context
id: tempId,
userId: targetUserId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// If data needs re-encryption
let processedHostData = newHostData;
if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
processedHostData = DataCrypto.encryptRecord(
"ssh_data",
newHostData,
targetUserId,
options.userDataKey,
);
}
// Remove temp ID to let database auto-generate real ID
delete processedHostData.id;
await getDb().insert(sshData).values(processedHostData);
imported++;
} catch (error) {
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
errors.push(
`SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
@@ -215,13 +212,14 @@ class UserDataImport {
return { imported, skipped, errors };
}
/**
* Import SSH credentials
*/
private static async importSshCredentials(
targetUserId: string,
credentials: any[],
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
options: {
replaceExisting: boolean;
dryRun: boolean;
userDataKey: Buffer | null;
},
) {
let imported = 0;
let skipped = 0;
@@ -234,31 +232,35 @@ class UserDataImport {
continue;
}
// Generate temporary ID for encryption context, then remove for database insert
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
const newCredentialData = {
...credential,
id: tempCredId, // Temporary ID for encryption context
id: tempCredId,
userId: targetUserId,
usageCount: 0, // Reset usage count
usageCount: 0,
lastUsed: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// If data needs re-encryption
let processedCredentialData = newCredentialData;
if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
processedCredentialData = DataCrypto.encryptRecord(
"ssh_credentials",
newCredentialData,
targetUserId,
options.userDataKey,
);
}
// Remove temp ID to let database auto-generate real ID
delete processedCredentialData.id;
await getDb().insert(sshCredentials).values(processedCredentialData);
imported++;
} catch (error) {
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
errors.push(
`SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
skipped++;
}
}
@@ -266,20 +268,16 @@ class UserDataImport {
return { imported, skipped, errors };
}
/**
* Import file manager data
*/
private static async importFileManagerData(
targetUserId: string,
fileManagerData: any,
options: { replaceExisting: boolean; dryRun: boolean }
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
try {
// Import recent files
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
for (const item of fileManagerData.recent) {
try {
@@ -294,13 +292,14 @@ class UserDataImport {
}
imported++;
} 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++;
}
}
}
// Import pinned files
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
for (const item of fileManagerData.pinned) {
try {
@@ -315,14 +314,18 @@ class UserDataImport {
}
imported++;
} 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++;
}
}
}
// Import shortcuts
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
if (
fileManagerData.shortcuts &&
Array.isArray(fileManagerData.shortcuts)
) {
for (const item of fileManagerData.shortcuts) {
try {
if (!options.dryRun) {
@@ -336,25 +339,26 @@ class UserDataImport {
}
imported++;
} 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++;
}
}
}
} 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 };
}
/**
* Import dismissed alerts
*/
private static async importDismissedAlerts(
targetUserId: string,
alerts: any[],
options: { replaceExisting: boolean; dryRun: boolean }
options: { replaceExisting: boolean; dryRun: boolean },
) {
let imported = 0;
let skipped = 0;
@@ -367,15 +371,14 @@ class UserDataImport {
continue;
}
// Check if alert already exists
const existing = await getDb()
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, targetUserId),
eq(dismissedAlerts.alertId, alert.alertId)
)
eq(dismissedAlerts.alertId, alert.alertId),
),
);
if (existing.length > 0 && !options.replaceExisting) {
@@ -401,7 +404,9 @@ class UserDataImport {
imported++;
} 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++;
}
}
@@ -409,13 +414,10 @@ class UserDataImport {
return { imported, skipped, errors };
}
/**
* Import from JSON string
*/
static async importUserDataFromJSON(
targetUserId: string,
jsonData: string,
options: ImportOptions = {}
options: ImportOptions = {},
): Promise<ImportResult> {
try {
const exportData: UserExportData = JSON.parse(jsonData);
@@ -429,4 +431,4 @@ class UserDataImport {
}
}
export { UserDataImport, type ImportOptions, type ImportResult };
export { UserDataImport, type ImportOptions, type ImportResult };

View File

@@ -52,7 +52,9 @@ export function VersionAlert({
<Download className="h-4 w-4" />
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
<AlertDescription>
{t("versionCheck.currentVersion", { version: updateInfo.localVersion })}
{t("versionCheck.currentVersion", {
version: updateInfo.localVersion,
})}
</AlertDescription>
</Alert>
);
@@ -70,13 +72,17 @@ export function VersionAlert({
latest: updateInfo.remoteVersion,
})}
</div>
{updateInfo.latest_release && (
<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">
{t("versionCheck.releasedOn", {
date: new Date(updateInfo.latest_release.published_at).toLocaleDateString(),
date: new Date(
updateInfo.latest_release.published_at,
).toLocaleDateString(),
})}
</div>
</div>
@@ -100,7 +106,7 @@ export function VersionAlert({
{t("versionCheck.downloadUpdate")}
</Button>
)}
{showDismiss && onDismiss && (
<Button
variant="ghost"

View File

@@ -18,7 +18,6 @@ export interface ElectronAPI {
invoke: (channel: string, ...args: any[]) => Promise<any>;
// Drag and drop API
createTempFile: (fileData: {
fileName: string;
content: string;

View File

@@ -2,7 +2,6 @@
// CENTRAL TYPE DEFINITIONS
// ============================================================================
// This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency.
import type { Client } from "ssh2";
@@ -25,7 +24,6 @@ export interface SSHHost {
keyPassword?: string;
keyType?: string;
// Autostart plaintext credentials
autostartPassword?: string;
autostartKey?: string;
autostartKeyPassword?: string;

View File

@@ -91,10 +91,8 @@ export function AdminSettings({
null,
);
// Simplified security state
const [securityInitialized, setSecurityInitialized] = React.useState(true);
// Database migration state
const [exportLoading, setExportLoading] = React.useState(false);
const [importLoading, setImportLoading] = React.useState(false);
const [importFile, setImportFile] = React.useState<File | null>(null);
@@ -103,9 +101,6 @@ export function AdminSettings({
const [importPassword, setImportPassword] = React.useState("");
React.useEffect(() => {
// JWT is now automatically sent via HttpOnly cookies
// No need to check for JWT cookie manually
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
@@ -147,9 +142,6 @@ export function AdminSettings({
}, []);
const fetchUsers = async () => {
// JWT is now automatically sent via HttpOnly cookies
// No need to check for JWT cookie manually
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
@@ -172,7 +164,6 @@ export function AdminSettings({
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
// JWT is now automatically sent via HttpOnly cookies
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
@@ -204,7 +195,6 @@ export function AdminSettings({
return;
}
// JWT is now automatically sent via HttpOnly cookies
try {
await updateOIDCConfig(oidcConfig);
toast.success(t("admin.oidcConfigurationUpdated"));
@@ -226,7 +216,6 @@ export function AdminSettings({
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
// JWT is now automatically sent via HttpOnly cookies
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
@@ -243,7 +232,6 @@ export function AdminSettings({
const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
// JWT is now automatically sent via HttpOnly cookies
try {
await removeAdminStatus(username);
toast.success(t("admin.adminStatusRemoved", { username }));
@@ -258,7 +246,6 @@ export function AdminSettings({
confirmWithToast(
t("admin.deleteUser", { username }),
async () => {
// JWT is now automatically sent via HttpOnly cookies
try {
await deleteUser(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 () => {
if (!showPasswordInput) {
setShowPasswordInput(true);
@@ -292,7 +271,6 @@ export function AdminSettings({
setExportLoading(true);
try {
// JWT is now automatically sent via HttpOnly cookies
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/export`
: "http://localhost:30001/database/export";
@@ -302,18 +280,19 @@ export function AdminSettings({
headers: {
"Content-Type": "application/json",
},
credentials: "include", // Include HttpOnly cookies
credentials: "include",
body: JSON.stringify({ password: exportPassword }),
});
if (response.ok) {
// Handle file download
const blob = await response.blob();
const contentDisposition = response.headers.get('content-disposition');
const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || 'termix-export.sqlite';
const contentDisposition = response.headers.get("content-disposition");
const filename =
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
"termix-export.sqlite";
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
@@ -352,19 +331,17 @@ export function AdminSettings({
setImportLoading(true);
try {
// JWT is now automatically sent via HttpOnly cookies
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/import`
: "http://localhost:30001/database/import";
// Create FormData for file upload
const formData = new FormData();
formData.append("file", importFile);
formData.append("password", importPassword);
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include", // Include HttpOnly cookies
credentials: "include",
body: formData,
});
@@ -372,23 +349,34 @@ export function AdminSettings({
const result = await response.json();
if (result.success) {
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 details = [];
if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`);
if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`);
if (summary.fileManagerItemsImported > 0) details.push(`${summary.fileManagerItemsImported} file manager items`);
if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`);
if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`);
if (summary.sshHostsImported > 0)
details.push(`${summary.sshHostsImported} SSH hosts`);
if (summary.sshCredentialsImported > 0)
details.push(`${summary.sshCredentialsImported} credentials`);
if (summary.fileManagerItemsImported > 0)
details.push(
`${summary.fileManagerItemsImported} file manager items`,
);
if (summary.dismissedAlertsImported > 0)
details.push(`${summary.dismissedAlertsImported} alerts`);
if (summary.settingsImported > 0)
details.push(`${summary.settingsImported} settings`);
toast.success(
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(', ')})` : ''}, ${skipped} items skipped`
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
);
setImportFile(null);
setImportPassword("");
// Refresh the page to show imported data
setTimeout(() => {
window.location.reload();
}, 1500);
@@ -412,7 +400,6 @@ export function AdminSettings({
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
@@ -856,18 +843,20 @@ export function AdminSettings({
</h3>
</div>
{/* Simple status display - read only */}
<div className="p-4 border rounded bg-card">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-green-500" />
<div>
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
<div className="text-xs text-green-500">{t("admin.encryptionEnabled")}</div>
<div className="text-sm font-medium">
{t("admin.encryptionStatus")}
</div>
<div className="text-xs text-green-500">
{t("admin.encryptionEnabled")}
</div>
</div>
</div>
</div>
{/* Data management functions - export/import */}
<div className="grid gap-3 md:grid-cols-2">
<div className="p-4 border rounded bg-card">
<div className="space-y-3">
@@ -887,7 +876,7 @@ export function AdminSettings({
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleExportDatabase();
}
}}
@@ -903,8 +892,7 @@ export function AdminSettings({
? t("admin.exporting")
: showPasswordInput
? t("admin.confirmExport")
: t("admin.export")
}
: t("admin.export")}
</Button>
{showPasswordInput && (
<Button
@@ -935,7 +923,9 @@ export function AdminSettings({
id="import-file-upload"
type="file"
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"
/>
<Button
@@ -945,7 +935,10 @@ export function AdminSettings({
>
<span
className="truncate"
title={importFile?.name || t("admin.pleaseSelectImportFile")}
title={
importFile?.name ||
t("admin.pleaseSelectImportFile")
}
>
{importFile
? importFile.name
@@ -962,7 +955,7 @@ export function AdminSettings({
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleImportDatabase();
}
}}
@@ -971,10 +964,14 @@ export function AdminSettings({
)}
<Button
onClick={handleImportDatabase}
disabled={importLoading || !importFile || !importPassword.trim()}
disabled={
importLoading || !importFile || !importPassword.trim()
}
className="w-full"
>
{importLoading ? t("admin.importing") : t("admin.import")}
{importLoading
? t("admin.importing")
: t("admin.import")}
</Button>
</div>
</div>

View File

@@ -222,7 +222,6 @@ export function CredentialEditor({
}
}, [editingCredential?.id, fullCredentialDetails, form]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (keyDetectionTimeoutRef.current) {
@@ -234,7 +233,6 @@ export function CredentialEditor({
};
}, []);
// Detect key type function
const handleKeyTypeDetection = async (
keyValue: string,
keyPassword?: string,
@@ -251,7 +249,6 @@ export function CredentialEditor({
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType("invalid");
console.warn("Key detection failed:", result.error);
}
} catch (error) {
setDetectedKeyType("error");
@@ -261,7 +258,6 @@ export function CredentialEditor({
}
};
// Debounced key type detection
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
@@ -271,7 +267,6 @@ export function CredentialEditor({
}, 1000);
};
// Detect public key type function
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null);
@@ -295,7 +290,6 @@ export function CredentialEditor({
}
};
// Debounced public key type detection
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
@@ -718,7 +712,6 @@ export function CredentialEditor({
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
@@ -770,7 +763,6 @@ export function CredentialEditor({
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
@@ -822,7 +814,6 @@ export function CredentialEditor({
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
@@ -924,7 +915,9 @@ export function CredentialEditor({
form.watch("keyPassword"),
);
}}
placeholder={t("placeholders.pastePrivateKey")}
placeholder={t(
"placeholders.pastePrivateKey",
)}
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
@@ -1044,9 +1037,7 @@ export function CredentialEditor({
);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(
result.publicKey,
);

View File

@@ -218,7 +218,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</SheetHeader>
<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">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
@@ -249,7 +248,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</Button>
</div>
{/* Tab Content */}
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">

View File

@@ -804,7 +804,6 @@ export function CredentialsManager({
</SheetHeader>
<div className="space-y-6">
{/* Credential Information Card */}
{deployingCredential && (
<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">
@@ -856,7 +855,6 @@ export function CredentialsManager({
</div>
)}
{/* Target Host Selection */}
<div className="space-y-3">
<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" />
@@ -888,7 +886,6 @@ export function CredentialsManager({
</Select>
</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="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" />

File diff suppressed because it is too large Load Diff

View File

@@ -107,7 +107,6 @@ export function FileManagerContextMenu({
useEffect(() => {
if (!isVisible) return;
// Adjust menu position to avoid going off screen
const adjustPosition = () => {
const menuWidth = 200;
const menuHeight = 300;
@@ -130,13 +129,10 @@ export function FileManagerContextMenu({
adjustPosition();
// Delay adding event listeners to avoid capturing the click that triggered the menu
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
// Click outside to close menu
const handleClickOutside = (event: MouseEvent) => {
// Check if click is inside menu
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
@@ -145,13 +141,11 @@ export function FileManagerContextMenu({
}
};
// Right-click to close menu (Windows behavior)
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
// Keyboard support
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -159,12 +153,10 @@ export function FileManagerContextMenu({
}
};
// Close menu on window blur
const handleBlur = () => {
onClose();
};
// Close menu on scroll (Windows behavior)
const handleScroll = () => {
onClose();
};
@@ -175,7 +167,6 @@ export function FileManagerContextMenu({
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
// Set cleanup function
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
@@ -183,7 +174,7 @@ export function FileManagerContextMenu({
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50); // 50ms delay to ensure we don't capture the click that triggered the menu
}, 50);
return () => {
clearTimeout(timeoutId);
@@ -204,13 +195,9 @@ export function FileManagerContextMenu({
(f) => f.type === "file" && f.executable,
);
// Build menu items
const menuItems: MenuItem[] = [];
if (isFileContext) {
// Menu when files/folders are selected
// Open terminal function - supports files and folders
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
@@ -229,7 +216,6 @@ export function FileManagerContextMenu({
});
}
// Run executable file function - only show for single executable files
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="w-4 h-4" />,
@@ -239,7 +225,6 @@ export function FileManagerContextMenu({
});
}
// Add separator (if above functions exist)
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
@@ -247,7 +232,6 @@ export function FileManagerContextMenu({
menuItems.push({ separator: true } as MenuItem);
}
// Preview function
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
@@ -257,7 +241,6 @@ export function FileManagerContextMenu({
});
}
// Download function - use proper download handler
if (hasFiles && onDownload) {
menuItems.push({
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") {
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) {
menuItems.push({
icon: <Bookmark className="w-4 h-4" />,
@@ -297,7 +278,6 @@ export function FileManagerContextMenu({
});
}
// Add separator (if above functions exist)
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
@@ -308,7 +288,6 @@ export function FileManagerContextMenu({
menuItems.push({ separator: true } as MenuItem);
}
// Rename function
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
@@ -318,7 +297,6 @@ export function FileManagerContextMenu({
});
}
// Copy function
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
@@ -330,7 +308,6 @@ export function FileManagerContextMenu({
});
}
// Cut function
if (onCut) {
menuItems.push({
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) {
menuItems.push({ separator: true } as MenuItem);
}
// Delete function
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
@@ -360,12 +335,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if delete function exists)
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
// Properties function
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
@@ -374,9 +347,6 @@ export function FileManagerContextMenu({
});
}
} else {
// Empty area right-click menu
// Open terminal in current directory
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
@@ -386,7 +356,6 @@ export function FileManagerContextMenu({
});
}
// Upload function
if (onUpload) {
menuItems.push({
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) {
menuItems.push({ separator: true } as MenuItem);
}
// New folder
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
@@ -411,7 +378,6 @@ export function FileManagerContextMenu({
});
}
// New file
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
@@ -421,12 +387,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if new functions exist)
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
// Refresh function
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
@@ -436,7 +400,6 @@ export function FileManagerContextMenu({
});
}
// Paste function
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
@@ -447,15 +410,12 @@ export function FileManagerContextMenu({
}
}
// Filter out consecutive separators
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
// If it's a separator, check if previous and next are also separators
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
// If previous or next is a separator, filter out current separator
if (prevItem?.separator || nextItem?.separator) {
return false;
}
@@ -463,7 +423,6 @@ export function FileManagerContextMenu({
return true;
});
// Remove separators at beginning and end
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
@@ -471,10 +430,8 @@ export function FileManagerContextMenu({
return (
<>
{/* Transparent overlay to capture click events */}
<div className="fixed inset-0 z-[99990]" />
{/* Menu body */}
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"

View File

@@ -15,7 +15,6 @@ import {
Upload,
ChevronLeft,
ChevronRight,
MoreHorizontal,
RefreshCw,
ArrowUp,
FileSymlink,
@@ -26,20 +25,16 @@ import {
import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js";
// Linus-style data structure: separate creation intent from actual files
interface CreateIntent {
id: string;
type: 'file' | 'directory';
type: "file" | "directory";
defaultName: string;
currentName: string;
}
// Format file size
function formatFileSize(bytes?: number): string {
// Handle undefined or null cases
if (bytes === undefined || bytes === null) return "-";
// Display 0-byte files as "0 B"
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -51,7 +46,6 @@ function formatFileSize(bytes?: number): string {
unitIndex++;
}
// Display one decimal place for values less than 10, integers for values greater than 10
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
@@ -95,7 +89,6 @@ interface FileManagerGridProps {
onSystemDragStart?: (files: FileItem[]) => void;
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
hasClipboard?: boolean;
// Linus-style creation intent props
createIntent?: CreateIntent | null;
onConfirmCreate?: (name: string) => void;
onCancelCreate?: () => void;
@@ -206,16 +199,12 @@ export function FileManagerGrid({
const gridRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState("");
// Unified drag state management
const [dragState, setDragState] = useState<DragState>({
type: "none",
files: [],
counter: 0,
});
// Global mouse move listener - for drag tooltip following
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === "internal" && dragState.files.length > 0) {
@@ -235,11 +224,9 @@ export function FileManagerGrid({
const editInputRef = useRef<HTMLInputElement>(null);
// Set initial name when starting edit
useEffect(() => {
if (editingFile) {
setEditingName(editingFile.name);
// Delay focus to ensure DOM is updated
setTimeout(() => {
editInputRef.current?.focus();
editInputRef.current?.select();
@@ -247,7 +234,6 @@ export function FileManagerGrid({
}
}, [editingFile]);
// Handle edit confirmation
const handleEditConfirm = () => {
if (
editingFile &&
@@ -260,13 +246,11 @@ export function FileManagerGrid({
onCancelEdit?.();
};
// Handle edit cancellation
const handleEditCancel = () => {
setEditingName("");
onCancelEdit?.();
};
// Handle input key events
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -277,9 +261,7 @@ export function FileManagerGrid({
}
};
// File drag handling function
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
// If dragged file is selected, drag all selected files
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
setDragState({
@@ -290,7 +272,6 @@ export function FileManagerGrid({
mousePosition: { x: e.clientX, y: e.clientY },
});
// Set drag data, add internal drag identifier
const dragData = {
type: "internal_files",
files: filesToDrag.map((f) => f.path),
@@ -303,7 +284,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Only set target when dragging to different file and not being dragged file
if (
dragState.type === "internal" &&
!dragState.files.some((f) => f.path === targetFile.path)
@@ -317,7 +297,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Clear drag target highlight
if (dragState.target?.path === targetFile.path) {
setDragState((prev) => ({ ...prev, target: undefined }));
}
@@ -332,46 +311,23 @@ export function FileManagerGrid({
return;
}
// Check if dragging to self
const isDroppingOnSelf = dragState.files.some(
(f) => f.path === targetFile.path,
);
if (isDroppingOnSelf) {
console.log("Ignoring drop on self");
setDragState({ type: "none", files: [], counter: 0 });
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") {
// Move operation
console.log(
"Moving files to directory:",
dragState.files.map((f) => f.name),
"to",
targetFile.name,
);
onFileDrop?.(dragState.files, targetFile);
} else if (
targetFile.type === "file" &&
dragState.files.length === 1 &&
dragState.files[0].type === "file"
) {
// Diff comparison operation
console.log(
"Comparing files:",
dragState.files[0].name,
"vs",
targetFile.name,
);
onFileDiff?.(dragState.files[0], targetFile);
} else {
// Invalid operation, notify user
console.log("Invalid drag operation");
}
setDragState({ type: "none", files: [], counter: 0 });
@@ -381,7 +337,6 @@ export function FileManagerGrid({
const draggedFiles = dragState.draggedFiles || [];
setDragState({ type: "none", files: [], counter: 0 });
// Trigger system-level drag end detection with dragged files
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
};
@@ -398,17 +353,14 @@ export function FileManagerGrid({
} | null>(null);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Navigation history management
const [navigationHistory, setNavigationHistory] = useState<string[]>([
currentPath,
]);
const [historyIndex, setHistoryIndex] = useState(0);
// Path editing state
const [isEditingPath, setIsEditingPath] = useState(false);
const [editPathValue, setEditPathValue] = useState(currentPath);
// Update navigation history
useEffect(() => {
const lastPath = navigationHistory[historyIndex];
if (currentPath !== lastPath) {
@@ -419,7 +371,6 @@ export function FileManagerGrid({
}
}, [currentPath]);
// Navigation functions
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
@@ -447,7 +398,6 @@ export function FileManagerGrid({
}
};
// Path navigation
const pathParts = currentPath.split("/").filter(Boolean);
const navigateToPath = (index: number) => {
if (index === -1) {
@@ -458,7 +408,6 @@ export function FileManagerGrid({
}
};
// Path editing functionality
const startEditingPath = () => {
setEditPathValue(currentPath);
setIsEditingPath(true);
@@ -472,7 +421,6 @@ export function FileManagerGrid({
const confirmEditingPath = () => {
const trimmedPath = editPathValue.trim();
if (trimmedPath) {
// Ensure path starts with /
const normalizedPath = trimmedPath.startsWith("/")
? trimmedPath
: "/" + trimmedPath;
@@ -491,31 +439,26 @@ export function FileManagerGrid({
}
};
// Sync editPathValue with currentPath
useEffect(() => {
if (!isEditingPath) {
setEditPathValue(currentPath);
}
}, [currentPath, isEditingPath]);
// Drag and drop handling - distinguish internal file drag and external file upload
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag) {
// Only show upload prompt for external file drag
setDragState((prev) => ({
...prev,
type: "external",
counter: prev.counter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// External drag detected
}
}
},
@@ -527,7 +470,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag && dragState.type === "external") {
@@ -549,11 +491,9 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (isInternalDrag) {
// Update mouse position
setDragState((prev) => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY },
@@ -566,15 +506,11 @@ export function FileManagerGrid({
[dragState.type],
);
// Mouse wheel event handling, ensure scrolling works normally
const handleWheel = useCallback((e: React.WheelEvent) => {
// Don't prevent default scroll behavior, let browser handle scrolling
e.stopPropagation();
}, []);
// Box selection functionality implementation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only start box selection in empty area, avoid interfering with file clicks
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -585,7 +521,6 @@ export function FileManagerGrid({
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
// Reset flag for just completed selection, prepare for new selection
setJustFinishedSelecting(false);
}
}, []);
@@ -604,7 +539,6 @@ export function FileManagerGrid({
setSelectionRect({ x, y, width, height });
// Detect intersection with file items, perform real-time selection
if (gridRef.current) {
const fileElements =
gridRef.current.querySelectorAll("[data-file-path]");
@@ -614,7 +548,6 @@ export function FileManagerGrid({
const elementRect = element.getBoundingClientRect();
const containerRect = gridRef.current!.getBoundingClientRect();
// Simplify coordinate calculation - directly use coordinates relative to container
const relativeElementRect = {
left: elementRect.left - containerRect.left,
top: elementRect.top - containerRect.top,
@@ -622,7 +555,6 @@ export function FileManagerGrid({
bottom: elementRect.bottom - containerRect.top,
};
// Selection box coordinates
const selectionBox = {
left: x,
top: y,
@@ -630,7 +562,6 @@ export function FileManagerGrid({
bottom: y + height,
};
// Check if intersecting
const intersects = !(
relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right ||
@@ -642,21 +573,13 @@ export function FileManagerGrid({
const filePath = element.getAttribute("data-file-path");
if (filePath) {
selectedPaths.push(filePath);
console.log("Selected file:", filePath);
}
}
});
console.log("Total selected paths:", selectedPaths.length);
// Update selected files
const newSelection = files.filter((file) =>
selectedPaths.includes(file.path),
);
console.log(
"New selection:",
newSelection.map((f) => f.name),
);
onSelectionChange(newSelection);
}
}
@@ -671,7 +594,6 @@ export function FileManagerGrid({
setSelectionStart(null);
setSelectionRect(null);
// Only consider as box selection when movement distance is large enough, otherwise it's a click
const startPos = selectionStart;
if (startPos) {
const rect = gridRef.current?.getBoundingClientRect();
@@ -683,13 +605,11 @@ export function FileManagerGrid({
);
if (distance > 5) {
// Real box selection, set flag to prevent immediate clearing
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
} else {
// Just a click, don't set flag, let handleGridClick handle normally
setJustFinishedSelecting(false);
}
}
@@ -699,7 +619,6 @@ export function FileManagerGrid({
[isSelecting, selectionStart],
);
// Global mouse event listener, ensure box selection can end outside container
useEffect(() => {
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isSelecting) {
@@ -707,7 +626,6 @@ export function FileManagerGrid({
setSelectionStart(null);
setSelectionRect(null);
// Global mouseup indicates drag box selection, set flag
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
@@ -747,58 +665,32 @@ export function FileManagerGrid({
e.stopPropagation();
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 });
} else if (dragState.type === "external") {
// External drag: handle file upload
if (onUpload && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files);
}
}
// Reset drag state
setDragState({ type: "none", files: [], counter: 0 });
},
[onUpload, onDownload, dragState],
);
// File selection handling
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
// Ensure grid gets focus to support keyboard events
if (gridRef.current) {
gridRef.current.focus();
}
console.log(
"File clicked:",
file.name,
"Current selected:",
selectedFiles.length,
);
if (event.detail === 2) {
// Double click to open
console.log("Double click - opening file");
onFileOpen(file);
} else {
// Single click to select
const multiSelect = event.ctrlKey || event.metaKey;
const rangeSelect = event.shiftKey;
console.log(
"Single click - multiSelect:",
multiSelect,
"rangeSelect:",
rangeSelect,
);
if (rangeSelect && selectedFiles.length > 0) {
// Range selection (Shift+click)
console.log("Range selection");
const lastSelected = selectedFiles[selectedFiles.length - 1];
const currentIndex = files.findIndex((f) => f.path === file.path);
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
@@ -807,36 +699,26 @@ export function FileManagerGrid({
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const rangeFiles = files.slice(start, end + 1);
console.log("Range selection result:", rangeFiles.length, "files");
onSelectionChange(rangeFiles);
}
} else if (multiSelect) {
// Multi-selection (Ctrl+click)
console.log("Multi selection");
const isSelected = selectedFiles.some((f) => f.path === file.path);
if (isSelected) {
console.log("Removing from selection");
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
} else {
console.log("Adding to selection");
onSelectionChange([...selectedFiles, file]);
}
} else {
// Single selection
console.log("Single selection - should select only:", file.name);
onSelectionChange([file]);
}
}
};
// Click empty area to cancel selection
const handleGridClick = (event: React.MouseEvent) => {
// Ensure grid gets focus to support keyboard events
if (gridRef.current) {
gridRef.current.focus();
}
// If just completed box selection, don't clear selection
if (
event.target === event.currentTarget &&
!isSelecting &&
@@ -846,10 +728,8 @@ export function FileManagerGrid({
}
};
// Keyboard support
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check if input box or editable element has focus, skip if so
const activeElement = document.activeElement;
if (
activeElement &&
@@ -868,7 +748,6 @@ export function FileManagerGrid({
case "A":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
console.log("Ctrl+A pressed - selecting all files:", files.length);
onSelectionChange([...files]);
}
break;
@@ -910,7 +789,6 @@ export function FileManagerGrid({
break;
case "Delete":
if (selectedFiles.length > 0 && onDelete) {
// Trigger delete operation
onDelete(selectedFiles);
}
break;
@@ -922,7 +800,7 @@ export function FileManagerGrid({
break;
case "y":
case "Y":
if ((event.ctrlKey || event.metaKey)) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onRefresh();
}
@@ -957,9 +835,7 @@ export function FileManagerGrid({
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
{/* Toolbar and path navigation */}
<div className="flex-shrink-0 border-b border-dark-border">
{/* Navigation buttons */}
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button
onClick={goBack}
@@ -1004,10 +880,8 @@ export function FileManagerGrid({
</button>
</div>
{/* Breadcrumb navigation */}
<div className="flex items-center px-3 py-2 text-sm">
{isEditingPath ? (
// Edit mode: path input box
<div className="flex-1 flex items-center gap-2">
<input
type="text"
@@ -1038,7 +912,6 @@ export function FileManagerGrid({
</button>
</div>
) : (
// View mode: breadcrumb navigation
<>
<button
onClick={() => navigateToPath(-1)}
@@ -1071,7 +944,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Main file grid - scroll area */}
<div className="flex-1 relative overflow-hidden">
<div
ref={gridRef}
@@ -1092,7 +964,6 @@ export function FileManagerGrid({
onContextMenu={(e) => onContextMenu?.(e)}
tabIndex={0}
>
{/* Drag hint overlay */}
{dragState.type === "external" && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
@@ -1128,7 +999,6 @@ export function FileManagerGrid({
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
{/* Linus-style creation intent UI - pure separation */}
{createIntent && (
<CreateIntentGridItem
intent={createIntent}
@@ -1169,10 +1039,8 @@ export function FileManagerGrid({
onDragEnd={handleFileDragEnd}
>
<div className="flex flex-col items-center text-center">
{/* File icon */}
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
{/* File name */}
<div className="w-full flex flex-col items-center">
{editingFile?.path === file.path ? (
<input
@@ -1221,7 +1089,6 @@ export function FileManagerGrid({
) : (
/* List view */
<div className="space-y-1">
{/* Linus-style creation intent UI - list view */}
{createIntent && (
<CreateIntentListItem
intent={createIntent}
@@ -1260,12 +1127,10 @@ export function FileManagerGrid({
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
{/* File icon */}
<div className="flex-shrink-0">
{getFileIcon(file, viewMode)}
</div>
{/* File info */}
<div className="flex-1 min-w-0">
{editingFile?.path === file.path ? (
<input
@@ -1305,7 +1170,6 @@ export function FileManagerGrid({
)}
</div>
{/* File size */}
<div className="flex-shrink-0 text-right">
{file.type === "file" &&
file.size !== undefined &&
@@ -1316,7 +1180,6 @@ export function FileManagerGrid({
)}
</div>
{/* Permission info */}
<div className="flex-shrink-0 text-right w-20">
{file.permissions && (
<p className="text-xs text-muted-foreground font-mono">
@@ -1330,7 +1193,6 @@ export function FileManagerGrid({
</div>
)}
{/* Selection rectangle */}
{isSelecting && selectionRect && (
<div
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
@@ -1345,7 +1207,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Status bar */}
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{t("fileManager.itemCount", { count: files.length })}</span>
@@ -1357,7 +1218,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
{dragState.type === "internal" &&
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
dragState.mousePosition &&
@@ -1365,27 +1225,43 @@ export function FileManagerGrid({
<div
className="fixed pointer-events-none"
style={{
left: Math.min(Math.max(dragState.mousePosition.x + 40, 0), window.innerWidth - 300),
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 0),
left: Math.min(
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,
}}
>
<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 ? (
dragState.target.type === "directory" ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.moveTo", { name: dragState.target.name })}
{t("fileManager.moveTo", {
name: dragState.target.name,
})}
</span>
</>
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.diffCompareWith", { name: dragState.target.name })}
{t("fileManager.diffCompareWith", {
name: dragState.target.name,
})}
</span>
</>
)
@@ -1393,20 +1269,21 @@ export function FileManagerGrid({
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.dragOutsideToDownload", { count: files.length })}
{t("fileManager.dragOutsideToDownload", {
count: files.length,
})}
</span>
</>
);
})()}
</div>
</div>,
document.body
document.body,
)}
</div>
);
}
// Linus-style creation intent component: Grid view
function CreateIntentGridItem({
intent,
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="flex flex-col items-center text-center">
<div className="mb-2">
{intent.type === 'directory' ? (
{intent.type === "directory" ? (
<Folder className="w-8 h-8 text-primary" />
) : (
<File className="w-8 h-8 text-primary" />
@@ -1453,14 +1330,17 @@ function CreateIntentGridItem({
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="w-full max-w-[120px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
</div>
);
}
// Linus-style creation intent component: List view
function CreateIntentListItem({
intent,
onConfirm,
@@ -1492,7 +1372,7 @@ function CreateIntentListItem({
return (
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10">
<div className="flex-shrink-0">
{intent.type === 'directory' ? (
{intent.type === "directory" ? (
<Folder className="w-6 h-6 text-primary" />
) : (
<File className="w-6 h-6 text-primary" />
@@ -1506,7 +1386,11 @@ function CreateIntentListItem({
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
);

View File

@@ -38,9 +38,9 @@ interface FileManagerSidebarProps {
currentPath: string;
onPathChange: (path: string) => void;
onLoadDirectory?: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void; // Added: handle file opening
onFileOpen?: (file: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number; // Used to trigger data refresh
refreshTrigger?: number;
}
export function FileManagerSidebar({
@@ -61,7 +61,6 @@ export function FileManagerSidebar({
new Set(["root"]),
);
// Right-click menu state
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
@@ -74,12 +73,10 @@ export function FileManagerSidebar({
item: null,
});
// Load quick access data
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
// Load directory tree (depends on sshSessionId)
useEffect(() => {
if (sshSessionId) {
loadDirectoryTree();
@@ -90,7 +87,6 @@ export function FileManagerSidebar({
if (!currentHost?.id) return;
try {
// Load recent files (limit to 5)
const recentData = await getRecentFiles(currentHost.id);
const recentItems = recentData.slice(0, 5).map((item: any) => ({
id: `recent-${item.id}`,
@@ -101,7 +97,6 @@ export function FileManagerSidebar({
}));
setRecentItems(recentItems);
// Load pinned files
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = pinnedData.map((item: any) => ({
id: `pinned-${item.id}`,
@@ -111,7 +106,6 @@ export function FileManagerSidebar({
}));
setPinnedItems(pinnedItems);
// Load folder shortcuts
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = shortcutData.map((item: any) => ({
id: `shortcut-${item.id}`,
@@ -122,20 +116,18 @@ export function FileManagerSidebar({
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
// If loading fails, keep empty arrays
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
};
// Delete functionality implementation
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
@@ -150,7 +142,7 @@ export function FileManagerSidebar({
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
@@ -163,7 +155,7 @@ export function FileManagerSidebar({
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
@@ -175,11 +167,10 @@ export function FileManagerSidebar({
if (!currentHost?.id || recentItems.length === 0) return;
try {
// Batch delete all recent files
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (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) => {
e.preventDefault();
e.stopPropagation();
@@ -204,7 +194,6 @@ export function FileManagerSidebar({
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
// Click outside to close menu
useEffect(() => {
if (!contextMenu.isVisible) return;
@@ -223,7 +212,6 @@ export function FileManagerSidebar({
}
};
// Delay adding listeners to avoid immediate trigger
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
@@ -240,10 +228,8 @@ export function FileManagerSidebar({
if (!sshSessionId) return;
try {
// Load root directory
const response = await listSSHFiles(sshSessionId, "/");
// listSSHFiles now always returns {files: Array, path: string} format
const rootFiles = response.files || [];
const rootFolders = rootFiles.filter(
(item: any) => item.type === "directory",
@@ -255,7 +241,7 @@ export function FileManagerSidebar({
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [], // Subdirectories will be loaded on demand
children: [],
}));
setDirectoryTree([
@@ -270,7 +256,6 @@ export function FileManagerSidebar({
]);
} catch (error) {
console.error("Failed to load directory tree:", error);
// If loading fails, show simple root directory
setDirectoryTree([
{
id: "root",
@@ -289,17 +274,14 @@ export function FileManagerSidebar({
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === "recent" || item.type === "pinned") {
// For file types, call file open callback
if (onFileOpen) {
onFileOpen(item);
} else {
// If no file open callback, switch to file directory
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
// Folder shortcuts directly switch to directory
onPathChange(item.path);
}
};
@@ -312,12 +294,10 @@ export function FileManagerSidebar({
} else {
newExpanded.add(folderId);
// Load subdirectories on demand
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
// listSSHFiles now always returns {files: Array, path: string} format
const subFiles = subResponse.files || [];
const subFolders = subFiles.filter(
(item: any) => item.type === "directory",
@@ -332,7 +312,6 @@ export function FileManagerSidebar({
children: [],
}));
// Update directory tree, add subdirectories for current folder
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map((item) => {
@@ -370,7 +349,6 @@ export function FileManagerSidebar({
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
// Only quick access items need right-click menu
if (
item.type === "recent" ||
item.type === "pinned" ||
@@ -438,7 +416,6 @@ export function FileManagerSidebar({
);
};
// Check if there are any quick access items
const hasQuickAccessItems =
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="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{/* Quick access area */}
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
@@ -464,7 +440,6 @@ export function FileManagerSidebar({
shortcuts,
)}
{/* Directory tree */}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
@@ -482,7 +457,6 @@ export function FileManagerSidebar({
</div>
</div>
{/* Right-click menu */}
{contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />

View File

@@ -33,8 +33,6 @@ export function DiffViewer({
file2,
sshSessionId,
sshHost,
onDownload1,
onDownload2,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
@@ -46,7 +44,6 @@ export function DiffViewer({
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
// Ensure SSH connection is valid
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
@@ -70,7 +67,6 @@ export function DiffViewer({
}
};
// Load file contents
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
@@ -81,10 +77,8 @@ export function DiffViewer({
setIsLoading(true);
setError(null);
// Ensure SSH connection is valid
await ensureSSHConnection();
// Load both files in parallel
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
@@ -106,13 +100,16 @@ export function DiffViewer({
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port
port: sshHost.port,
}),
);
} else {
setError(
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) => {
try {
await ensureSSHConnection();
@@ -147,15 +143,20 @@ export function DiffViewer({
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }));
toast.success(
t("fileManager.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: any) {
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 ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
@@ -190,7 +191,6 @@ export function DiffViewer({
return languageMap[ext || ""] || "plaintext";
};
// Initial load
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
@@ -200,7 +200,9 @@ export function DiffViewer({
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("fileManager.loadingFileComparison")}</p>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
@@ -223,12 +225,13 @@ export function DiffViewer({
return (
<div className="h-full flex flex-col bg-dark-bg">
{/* Toolbar */}
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">{t("fileManager.compare")}:</span>
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
@@ -238,7 +241,6 @@ export function DiffViewer({
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<Button
variant="outline"
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>
{/* Line number toggle */}
<Button
variant="outline"
size="sm"
@@ -264,7 +267,6 @@ export function DiffViewer({
)}
</Button>
{/* Download buttons */}
<Button
variant="outline"
size="sm"
@@ -285,7 +287,6 @@ export function DiffViewer({
{file2.name}
</Button>
{/* Refresh button */}
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
@@ -293,7 +294,6 @@ export function DiffViewer({
</div>
</div>
{/* Diff editor */}
<div className="flex-1">
<DiffEditor
original={content1}
@@ -322,7 +322,9 @@ export function DiffViewer({
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("fileManager.initializingEditor")}</p>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}

View File

@@ -30,7 +30,6 @@ export function DiffWindow({
const currentWindow = windows.find((w) => w.id === windowId);
// Window operation handling
const handleClose = () => {
closeWindow(windowId);
};
@@ -49,7 +48,10 @@ export function DiffWindow({
return (
<DraggableWindow
title={t("fileManager.fileComparison", { file1: file1.name, file2: file2.name })}
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}

View File

@@ -39,7 +39,6 @@ export function DraggableWindow({
targetSize,
}: DraggableWindowProps) {
const { t } = useTranslation();
// Window state
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
@@ -49,7 +48,6 @@ export function DraggableWindow({
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
// Drag and resize start positions
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
@@ -57,17 +55,14 @@ export function DraggableWindow({
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
// Handle target size changes for media files
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
// Calculate appropriate window size maintaining aspect ratio
let newWidth = Math.min(targetSize.width + 50, maxWidth); // Add padding for UI
let newHeight = Math.min(targetSize.height + 150, maxHeight); // Add padding for header/footer
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
// If still too large, scale down maintaining aspect ratio
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
@@ -77,26 +72,22 @@ export function DraggableWindow({
newHeight = Math.floor(newHeight * scale);
}
// Ensure minimum size
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
// Center the window
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2)
y: Math.max(0, (window.innerHeight - newHeight) / 2),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
// Handle window focus
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
// Drag handling
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
@@ -119,44 +110,44 @@ export function DraggableWindow({
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
// Find the positioning container by checking parent hierarchy
const windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (position === 'relative' || position === 'absolute' || position === 'fixed' || transform !== 'none') {
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
// Calculate boundaries based on the actual positioning context
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
// Window is positioned relative to a positioning container
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
// Window is positioned relative to viewport
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
// Ensure window stays within boundaries
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
@@ -175,14 +166,12 @@ export function DraggableWindow({
let newX = windowStart.x;
let newY = windowStart.y;
// Handle horizontal resizing
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
// Only move position if we're actually changing size
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
@@ -190,14 +179,12 @@ export function DraggableWindow({
}
}
// Handle vertical resizing
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
// Only move position if we're actually changing size
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
@@ -205,7 +192,6 @@ export function DraggableWindow({
}
}
// Ensure window stays within viewport
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
@@ -234,7 +220,6 @@ export function DraggableWindow({
setResizeDirection("");
}, []);
// Resize handling
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
@@ -251,7 +236,6 @@ export function DraggableWindow({
[isMaximized, position, size, onFocus],
);
// Global event listeners
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
@@ -268,7 +252,6 @@ export function DraggableWindow({
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
// Double-click title bar to maximize/restore
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
@@ -290,7 +273,6 @@ export function DraggableWindow({
}}
onClick={handleWindowClick}
>
{/* Title bar */}
<div
ref={titleBarRef}
className={cn(
@@ -349,7 +331,6 @@ export function DraggableWindow({
</div>
</div>
{/* Window content */}
<div
className="flex-1 overflow-auto"
style={{ height: "calc(100% - 40px)" }}
@@ -357,10 +338,8 @@ export function DraggableWindow({
{children}
</div>
{/* Resize borders - only show when not maximized */}
{!isMaximized && (
<>
{/* Edge resize */}
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
@@ -378,7 +357,6 @@ export function DraggableWindow({
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
{/* Corner resize */}
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}

View File

@@ -51,7 +51,12 @@ import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
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 { PhotoProvider, PhotoView } from "react-photo-view";
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 { 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 {
name: string;
@@ -87,15 +91,16 @@ interface FileViewerProps {
onContentChange?: (content: string) => void;
onSave?: (content: string) => 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 {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// Special filename handling
if (["dockerfile"].includes(baseName)) {
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" />;
}
// Get file type and icon
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
@@ -239,17 +243,14 @@ function getFileType(filename: string): {
}
}
// Get CodeMirror language extension
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// Special filename handling
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
// Map by file extension
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
@@ -288,7 +289,6 @@ function getLanguageExtension(filename: string) {
return language ? loadLanguage(language) : null;
}
// Format file size
function formatFileSize(bytes?: number, t?: any): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
@@ -328,32 +328,25 @@ export function FileViewer({
const fileTypeInfo = getFileType(file.name);
// File size limits - remove hard limits, support large file handling
const WARNING_SIZE = 50 * 1024 * 1024; // 50MB warning
const MAX_SIZE = Number.MAX_SAFE_INTEGER; // Remove hard limits
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
// Check if should display as text
const shouldShowAsText =
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
// Check if file is too large
const isLargeFile = file.size && file.size > WARNING_SIZE;
const isTooLarge = file.size && file.size > MAX_SIZE;
// Sync external content changes
useEffect(() => {
setEditedContent(content);
// Only update originalContent when savedContent is updated
if (savedContent) {
setOriginalContent(savedContent);
}
// Fix: Compare current content with saved content properly
setHasChanges(content !== savedContent);
// If unknown file type and file is large, show warning
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
@@ -361,59 +354,46 @@ export function FileViewer({
}
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
// Handle content changes
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
// Fix: Compare with savedContent instead of originalContent for consistency
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
// Save file
const handleSave = () => {
onSave?.(editedContent);
// Note: Don't update originalContent here, as it will be updated via savedContent prop
};
// Revert file
const handleRevert = () => {
setEditedContent(savedContent);
setHasChanges(false);
onContentChange?.(savedContent);
};
// Handle save shortcut specifically
useEffect(() => {
if (!editorFocused || !isEditable) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle Ctrl+S for custom save, let CodeMirror handle everything else
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && e.key.toLowerCase() === 's') {
if (isCtrl && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSave();
}
};
// Add event listener with capture for save shortcut only
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [editorFocused, isEditable, handleSave]);
// Handle user confirmation to open large file
const handleConfirmOpenAsText = () => {
setForceShowAsText(true);
setShowLargeFileWarning(false);
};
// Handle user rejection to open large file
const handleCancelOpenAsText = () => {
setShowLargeFileWarning(false);
};
@@ -431,7 +411,6 @@ export function FileViewer({
return (
<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 items-center justify-between">
<div className="flex items-center gap-3">
@@ -442,7 +421,11 @@ export function FileViewer({
<h3 className="font-medium text-foreground">{file.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<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
className={cn(
"px-2 py-1 rounded-full text-xs",
@@ -457,13 +440,11 @@ export function FileViewer({
</div>
<div className="flex items-center gap-2">
{/* Search button */}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use CodeMirror's proper API to open search panel
if (editorRef.current) {
const view = editorRef.current.view;
if (view) {
@@ -477,7 +458,6 @@ export function FileViewer({
<Search className="w-4 h-4" />
</Button>
)}
{/* Keyboard shortcuts help */}
{isEditable && (
<Button
variant="ghost"
@@ -526,11 +506,12 @@ export function FileViewer({
</div>
</div>
{/* Keyboard shortcuts help panel */}
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<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
variant="ghost"
size="sm"
@@ -542,60 +523,88 @@ export function FileViewer({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<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="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 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="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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 className="flex justify-between">
<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>
@@ -603,9 +612,7 @@ export function FileViewer({
</div>
)}
{/* File content */}
<div className="flex-1 overflow-hidden">
{/* Large file warning dialog */}
{showLargeFileWarning && (
<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">
@@ -616,7 +623,9 @@ export function FileViewer({
{t("fileManager.largeFileWarning")}
</h3>
<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>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
@@ -667,19 +676,15 @@ export function FileViewer({
</div>
)}
{/* Image preview with react-photo-view */}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
// Error state
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">
{file.name}
</p>
<p className="text-sm mb-4">{file.name}</p>
{onDownload && (
<Button
variant="outline"
@@ -703,12 +708,15 @@ export function FileViewer({
setImageLoading(false);
setImageLoadError(false);
// Get natural dimensions and notify parent
const img = e.currentTarget;
if (onMediaDimensionsChange && img.naturalWidth && img.naturalHeight) {
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight
height: img.naturalHeight,
});
}
}}
@@ -721,23 +729,22 @@ export function FileViewer({
</PhotoProvider>
)}
{/* Loading state */}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Loading image...</p>
<p className="text-sm text-muted-foreground">
Loading image...
</p>
</div>
</div>
)}
</div>
)}
{/* Unified text and code file editor */}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
// Unified CodeMirror editor for all text-based files
<CodeMirror
ref={editorRef}
value={editedContent}
@@ -756,20 +763,18 @@ export function FileViewer({
...searchKeymap,
...historyKeymap,
...completionKeymap,
// Custom keybindings
{
key: "Mod-/",
run: toggleComment,
preventDefault: true
preventDefault: true,
},
{
key: "Mod-h",
run: () => {
// Let CodeMirror search handle this, just prevent browser default
return false; // Return false to let search keymap handle it
return false;
},
preventDefault: true
}
preventDefault: true,
},
]),
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">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
@@ -808,22 +812,29 @@ export function FileViewer({
</div>
)}
{/* Video file preview with enhanced HTML5 support */}
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-4xl">
{(() => {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case 'mp4': return 'video/mp4';
case 'webm': return 'video/webm';
case 'mkv': return 'video/x-matroska';
case 'avi': 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';
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "mkv":
return "video/x-matroska";
case "avi":
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"
style={{
maxHeight: "calc(100vh - 200px)",
backgroundColor: "#000"
backgroundColor: "#000",
}}
preload="metadata"
onError={(e) => {
console.error('Video playback error:', e.currentTarget.error);
}}
onLoadStart={() => {
console.log('Video loading started...');
console.error(
"Video playback error:",
e.currentTarget.error,
);
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
console.log('Video metadata loaded, dimensions:', video.videoWidth, 'x', video.videoHeight);
// Get video dimensions and notify parent
if (onMediaDimensionsChange && video.videoWidth && video.videoHeight) {
if (
onMediaDimensionsChange &&
video.videoWidth &&
video.videoHeight
) {
onMediaDimensionsChange({
width: video.videoWidth,
height: video.videoHeight
height: video.videoHeight,
});
}
}}
onCanPlay={() => {
console.log('Video can start playing');
}}
>
<source src={videoUrl} type={mimeType} />
<div className="text-center text-muted-foreground p-4">
<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 && (
<Button
variant="outline"
@@ -884,10 +896,8 @@ export function FileViewer({
</div>
)}
{/* Markdown file editor with live preview */}
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
<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 items-center justify-between">
<div className="flex items-center gap-2">
@@ -908,17 +918,13 @@ export function FileViewer({
{t("fileManager.preview")}
</Button>
</div>
<div className="flex items-center gap-2">
{/* Save button removed - using the main header save button instead */}
</div>
<div className="flex items-center gap-2"></div>
</div>
</div>
{/* Markdown content area */}
<div className="flex-1 flex overflow-hidden">
{markdownEditMode ? (
<>
{/* Editor pane */}
<div className="flex-1 border-r border-border">
<div className="h-full p-4 bg-background">
<textarea
@@ -933,14 +939,21 @@ export function FileViewer({
</div>
</div>
{/* Preview pane */}
<div className="flex-1 overflow-auto bg-muted/10">
<div className="p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
code({
node,
inline,
className,
children,
...props
}) {
const match = /language-(\w+)/.exec(
className || "",
);
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
@@ -949,10 +962,13 @@ export function FileViewer({
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, '')}
{String(children).replace(/\n$/, "")}
</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}
</code>
);
@@ -993,9 +1009,7 @@ export function FileViewer({
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">
{children}
</li>
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<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>
),
thead: ({ children }) => (
<thead className="bg-muted">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody>
{children}
</tbody>
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">
{children}
@@ -1059,7 +1067,7 @@ export function FileViewer({
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
@@ -1068,10 +1076,13 @@ export function FileViewer({
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, '')}
{String(children).replace(/\n$/, "")}
</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}
</code>
);
@@ -1112,9 +1123,7 @@ export function FileViewer({
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">
{children}
</li>
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<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>
),
thead: ({ children }) => (
<thead className="bg-muted">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody>
{children}
</tbody>
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">
{children}
</tr>
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left font-semibold text-foreground">
@@ -1174,10 +1175,8 @@ export function FileViewer({
</div>
)}
{/* PDF file preview with react-pdf */}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<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 items-center justify-between">
<div className="flex items-center gap-4">
@@ -1191,12 +1190,17 @@ export function FileViewer({
{t("fileManager.previous")}
</Button>
<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>
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.min(numPages || 1, pageNumber + 1))}
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
@@ -1236,13 +1240,14 @@ export function FileViewer({
</div>
</div>
{/* PDF Content */}
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<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">
There was an error loading this PDF file.
</p>
@@ -1264,22 +1269,23 @@ export function FileViewer({
setNumPages(numPages);
setPdfError(false);
// Notify parent about PDF dimensions for window sizing
if (onMediaDimensionsChange) {
onMediaDimensionsChange({
width: 800,
height: 600
height: 600,
});
}
}}
onLoadError={(error) => {
console.error('PDF load error:', error);
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<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>
<p className="text-sm text-muted-foreground">Loading PDF...</p>
<p className="text-sm text-muted-foreground">
Loading PDF...
</p>
</div>
}
>
@@ -1290,7 +1296,9 @@ export function FileViewer({
loading={
<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>
<p className="text-xs text-muted-foreground">Loading page...</p>
<p className="text-xs text-muted-foreground">
Loading page...
</p>
</div>
}
/>
@@ -1301,21 +1309,27 @@ export function FileViewer({
</div>
)}
{/* Audio file preview with react-h5-audio-player */}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
{(() => {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case 'mp3': return 'audio/mpeg';
case 'wav': return 'audio/wav';
case 'flac': return 'audio/flac';
case 'ogg': return 'audio/ogg';
case 'aac': return 'audio/aac';
case 'm4a': return 'audio/mp4';
default: return 'audio/mpeg';
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
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 (
<div className="space-y-4">
{/* Album artwork placeholder */}
<div className="flex justify-center">
<div
className={cn(
@@ -1335,7 +1348,6 @@ export function FileViewer({
</div>
</div>
{/* Track info */}
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
@@ -1345,30 +1357,20 @@ export function FileViewer({
</p>
</div>
{/* Audio Player */}
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onPlay={() => {
console.log('Audio playback started');
}}
onPause={() => {
console.log('Audio playback paused');
}}
onLoadedMetadata={(e) => {
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) {
onMediaDimensionsChange({
width: 600,
height: 400
height: 400,
});
}
}}
onError={(e) => {
console.error('Audio playback error:', e);
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
@@ -1384,7 +1386,6 @@ export function FileViewer({
</div>
)}
{/* Unknown file type - only show when cannot display as text and no warning */}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
@@ -1413,7 +1414,6 @@ export function FileViewer({
)}
</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 justify-between items-center">
<span>{file.path}</span>

View File

@@ -44,8 +44,7 @@ interface FileWindowProps {
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
// readOnly parameter removed, determined internally by FileViewer based on file type
onFileNotFound?: (file: FileItem) => void;
}
export function FileWindow({
@@ -57,13 +56,8 @@ export function FileWindow({
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const {
closeWindow,
maximizeWindow,
focusWindow,
updateWindow,
windows,
} = useWindowManager();
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
useWindowManager();
const { t } = useTranslation();
@@ -71,22 +65,18 @@ export function FileWindow({
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
// Ensure SSH connection is valid
const ensureSSHConnection = async () => {
try {
// First check SSH connection status
const status = await getSSHStatus(sshSessionId);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log("SSH not connected, attempting to reconnect...");
// Re-establish connection
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
@@ -99,17 +89,13 @@ export function FileWindow({
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
console.log("SSH reconnection successful");
}
} catch (error) {
console.log("SSH connection check/reconnect failed:", error);
// Even if connection fails, try to continue and let specific API calls handle errors
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
@@ -117,23 +103,19 @@ export function FileWindow({
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent); // Initialize pending content
setPendingContent(fileContent);
// If file size is unknown, calculate size based on content
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
// Determine if editable based on file type: all except media files are editable
const mediaExtensions = [
// Image files
"jpg",
"jpeg",
"png",
@@ -143,7 +125,6 @@ export function FileWindow({
"webp",
"tiff",
"ico",
// Audio files
"mp3",
"wav",
"ogg",
@@ -151,7 +132,6 @@ export function FileWindow({
"flac",
"m4a",
"wma",
// Video files
"mp4",
"avi",
"mov",
@@ -160,7 +140,6 @@ export function FileWindow({
"mkv",
"webm",
"m4v",
// Archive files
"zip",
"rar",
"7z",
@@ -168,7 +147,6 @@ export function FileWindow({
"gz",
"bz2",
"xz",
// Binary files
"exe",
"dll",
"so",
@@ -178,28 +156,25 @@ export function FileWindow({
];
const extension = file.name.split(".").pop()?.toLowerCase();
// Only media files and binary files are not editable, all other files are editable
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) {
console.error("Failed to load file:", error);
// Check if it's a large file error
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000, // 10 seconds for important message
duration: 10000,
});
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
// If connection error, provide more specific error message
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
// Check if file not found (common error messages from cat command)
const errorMessage = errorData?.error || error.message || "Unknown error";
const errorMessage =
errorData?.error || error.message || "Unknown error";
const isFileNotFound =
(error as any).isFileNotFound ||
errorData?.fileNotFound ||
@@ -211,19 +186,21 @@ export function FileWindow({
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
// Notify parent component about the missing file for cleanup
onFileNotFound(file);
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
// Close this window since the file doesn't exist
closeWindow(windowId);
return; // Exit early to prevent showing empty editor
return;
} else {
toast.error(t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred") ?
t("fileManager.serverErrorOccurred") :
errorMessage
}));
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
@@ -234,19 +211,16 @@ export function FileWindow({
loadFileContent();
}, [file, sshSessionId, sshHost]);
// Save file
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent(""); // Clear pending content
setPendingContent("");
// Clear auto-save timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
@@ -256,7 +230,6 @@ export function FileWindow({
} catch (error: any) {
console.error("Failed to save file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
@@ -265,36 +238,33 @@ export function FileWindow({
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
toast.error(
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsLoading(false);
}
};
// Handle content changes - set 1-minute auto-save
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
// Clear previous timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
// Set new 1-minute auto-save timer
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log("Auto-saving file...");
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000); // 1 minute = 60000 milliseconds
}, 60000);
};
// Cleanup timer
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
@@ -303,16 +273,13 @@ export function FileWindow({
};
}, []);
// Download file
const handleDownload = async () => {
try {
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
// Convert base64 to blob and trigger download
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
@@ -337,7 +304,6 @@ export function FileWindow({
} catch (error: any) {
console.error("Failed to download file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
@@ -353,7 +319,6 @@ export function FileWindow({
}
};
// Window operation handling
const handleClose = () => {
closeWindow(windowId);
};
@@ -366,9 +331,10 @@ export function FileWindow({
focusWindow(windowId);
};
// Handle media dimensions change
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
console.log('Media dimensions received:', dimensions);
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
@@ -397,7 +363,7 @@ export function FileWindow({
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}

View File

@@ -39,10 +39,8 @@ export function TerminalWindow({
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
// Get current window state
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
console.warn(`Window with id ${windowId} not found`);
return null;
}
@@ -65,7 +63,10 @@ export function TerminalWindow({
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", { host: hostConfig.name, path: initialPath })
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (

View File

@@ -35,13 +35,11 @@ export function WindowManager({ children }: WindowManagerProps) {
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
// Open new window
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
// Calculate offset position to avoid windows completely overlapping
const offset = (windows.length % 5) * 30;
const adjustedX = windowData.x + offset;
const adjustedY = windowData.y + offset;
@@ -60,12 +58,10 @@ export function WindowManager({ children }: WindowManagerProps) {
[windows.length],
);
// Close window
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
// Minimize window
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
@@ -74,7 +70,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}, []);
// Maximize/restore window
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
@@ -83,7 +78,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}, []);
// Focus window (bring to top)
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
@@ -94,7 +88,6 @@ export function WindowManager({ children }: WindowManagerProps) {
});
}, []);
// Update window properties
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
@@ -117,7 +110,6 @@ export function WindowManager({ children }: WindowManagerProps) {
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
{/* Render all windows */}
<div className="window-container">
{windows.map((window) => (
<div key={window.id}>
@@ -131,7 +123,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}
// Hook for using window manager
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {

View File

@@ -450,30 +450,31 @@ export function HostManagerEditor({
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
}
// Handle AutoStart plaintext cache management
if (savedHost && savedHost.id && data.tunnelConnections) {
const hasAutoStartTunnels = data.tunnelConnections.some(tunnel => tunnel.autoStart);
const hasAutoStartTunnels = data.tunnelConnections.some(
(tunnel) => tunnel.autoStart,
);
if (hasAutoStartTunnels) {
// User has enabled autoStart on some tunnels
// Need to ensure plaintext cache exists for this host
try {
await enableAutoStart(savedHost.id);
console.log(`AutoStart plaintext cache enabled for SSH host ${savedHost.id}`);
} catch (error) {
console.warn(`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
// Don't fail the whole operation if cache setup fails
toast.warning(t("hosts.autoStartEnableFailed", { name: data.name }));
console.warn(
`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
error,
);
toast.warning(
t("hosts.autoStartEnableFailed", { name: data.name }),
);
}
} else {
// User has disabled autoStart on all tunnels
// Clean up plaintext cache for this host
try {
await disableAutoStart(savedHost.id);
console.log(`AutoStart plaintext cache disabled for SSH host ${savedHost.id}`);
} catch (error) {
console.warn(`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
// Don't fail the whole operation
console.warn(
`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
error,
);
}
}
}
@@ -990,7 +991,9 @@ export function HostManagerEditor({
: ""
}
onChange={(value) => field.onChange(value)}
placeholder={t("placeholders.pastePrivateKey")}
placeholder={t(
"placeholders.pastePrivateKey",
)}
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"

View File

@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,

View File

@@ -180,7 +180,6 @@ export function Server({
return (
<div style={wrapperStyle} className={containerClass}>
<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 items-center gap-4 min-w-0">
<div className="min-w-0">
@@ -271,184 +270,183 @@ export function Server({
</div>
<Separator className="p-0.25 w-full" />
{/* Stats */}
{showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<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>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</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>
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<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>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</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 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>
) : !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>
<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 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 className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{/* 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 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 total = metrics?.memory?.totalGiB;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number"
? `${used.toFixed(1)} GiB`
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
return `Free: ${free} GiB`;
})()}
</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 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>
{/* 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="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div>
{/* 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="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
<div className="space-y-2">
<div className="flex justify-between items-center">
<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 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})`;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</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 used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</div>
</div>
</div>
</div>
</div>
)}
)}
</div>
)}

View File

@@ -36,18 +36,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
},
ref,
) {
// DEBUG: Add global JWT test function (only once)
if (typeof window !== 'undefined' && !(window as any).testJWT) {
if (typeof window !== "undefined" && !(window as any).testJWT) {
(window as any).testJWT = () => {
const jwt = getCookie("jwt");
console.log("Manual JWT Test:", {
isElectron: isElectron(),
rawCookie: document.cookie,
localStorage: localStorage.getItem("jwt"),
getCookieResult: jwt,
jwtLength: jwt?.length || 0,
jwtFirst20: jwt?.substring(0, 20) || "empty"
});
return jwt;
};
}
@@ -83,35 +74,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isVisibleRef.current = isVisible;
}, [isVisible]);
// Monitor authentication state - Linus principle: explicit state management
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
// Only update state if it actually changed - prevent unnecessary re-renders
setIsAuthenticated(prev => {
setIsAuthenticated((prev) => {
if (prev !== isAuth) {
console.debug("Auth State Changed:", {
from: prev,
to: isAuth,
jwtPresent: !!jwtToken,
timestamp: new Date().toISOString()
});
return isAuth;
}
return prev; // No change, don't trigger re-render
return prev;
});
};
// Check immediately
checkAuth();
// Reduced frequency - check every 5 seconds instead of every second
const authCheckInterval = setInterval(checkAuth, 5000);
return () => clearInterval(authCheckInterval);
}, []); // No dependencies - prevent infinite loop
}, []);
function hardRefresh() {
try {
@@ -187,8 +168,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
[terminal],
);
// Resize handling moved to AppView to avoid conflicts - Linus principle: eliminate duplicate complexity
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
@@ -207,7 +186,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isReconnectingRef.current ||
isConnectingRef.current
) {
console.debug("Skipping reconnection - already in progress or blocked");
return;
}
@@ -245,7 +223,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return;
}
// Verify authentication before attempting reconnection
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn("Reconnection cancelled - no authentication token");
@@ -266,9 +243,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
function connectToHost(cols: number, rows: number) {
// Prevent duplicate connections - Linus principle: fail fast
if (isConnectingRef.current) {
console.debug("Skipping connection - already connecting");
return;
}
@@ -280,26 +255,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
window.location.port === "5173" ||
window.location.port === "");
// Get JWT token for WebSocket authentication (from cookie, not localStorage)
const jwtToken = getCookie("jwt");
// DEBUG: Log authentication issues only
if (!jwtToken || jwtToken.trim() === "") {
console.debug("JWT Debug Info:", {
isElectron: isElectron(),
rawCookie: isElectron() ? localStorage.getItem("jwt") : document.cookie,
jwtToken: jwtToken,
isEmpty: true
});
}
if (!jwtToken || jwtToken.trim() === "") {
console.error("No JWT token available for WebSocket connection");
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
isConnectingRef.current = false; // Reset on auth failure
// Don't show toast here - let auth system handle it
isConnectingRef.current = false;
return;
}
@@ -317,13 +280,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
console.log("Closing existing WebSocket connection before creating new one");
if (
webSocketRef.current &&
webSocketRef.current.readyState !== WebSocket.CLOSED
) {
webSocketRef.current.close();
}
// Clear existing intervals/timeouts
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
@@ -333,18 +296,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
connectionTimeoutRef.current = null;
}
// Add JWT token as query parameter for authentication
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
// DEBUG: Log WebSocket connection details
console.log("Creating WebSocket connection:", {
baseWsUrl,
jwtTokenLength: jwtToken.length,
jwtTokenStart: jwtToken.substring(0, 20),
encodedTokenLength: encodeURIComponent(jwtToken).length,
wsUrl: wsUrl.length > 100 ? `${wsUrl.substring(0, 100)}...` : wsUrl
});
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
@@ -439,7 +392,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} else if (msg.type === "connected") {
setIsConnected(true);
setIsConnecting(false);
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -467,25 +420,21 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener("close", (event) => {
setIsConnected(false);
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
if (terminal) {
terminal.clear();
}
// Handle authentication errors (code 1008)
if (event.code === 1008) {
console.error("WebSocket authentication failed:", event.reason);
setConnectionError("Authentication failed - please re-login");
setIsConnecting(false);
shouldNotReconnectRef.current = true;
// Clear invalid JWT token
localStorage.removeItem("jwt");
// Show authentication error message
toast.error("Authentication failed. Please log in again.");
// Don't attempt to reconnect on auth failure
return;
}
@@ -501,7 +450,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener("error", (event) => {
setIsConnected(false);
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
setConnectionError(t("terminal.websocketError"));
if (terminal) {
terminal.clear();
@@ -546,12 +495,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
if (!isAuthenticated) {
console.debug("Terminal setup delayed - waiting for authentication");
return;
}
terminal.options = {
cursorBlink: true,
cursorStyle: "bar",
@@ -563,7 +506,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
allowTransparency: true,
convertEol: true,
windowsMode: false,
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
@@ -604,32 +546,26 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
};
element?.addEventListener("contextmenu", handleContextMenu);
// Add macOS-specific keyboard event handling for special characters
const handleMacKeyboard = (e: KeyboardEvent) => {
// Detect macOS
const isMacOS =
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
if (!isMacOS) return;
// Handle Option key combinations for special characters
if (e.altKey && !e.metaKey && !e.ctrlKey) {
// Use both e.key and e.code to handle different keyboard layouts
const keyMappings: { [key: string]: string } = {
// Using e.key values
"7": "|", // Option+7 = pipe symbol
"2": "", // Option+2 = euro symbol
"8": "[", // Option+8 = left bracket
"9": "]", // Option+9 = right bracket
l: "@", // Option+L = at symbol
L: "@", // Option+L = at symbol (uppercase)
// Using e.code values as fallback
Digit7: "|", // Option+7 = pipe symbol
Digit2: "", // Option+2 = euro symbol
Digit8: "[", // Option+8 = left bracket
Digit9: "]", // Option+9 = right bracket
KeyL: "@", // Option+L = at symbol
"7": "|",
"2": "",
"8": "[",
"9": "]",
l: "@",
L: "@",
Digit7: "|",
Digit2: "€",
Digit8: "[",
Digit9: "]",
KeyL: "@",
};
const char = keyMappings[e.key] || keyMappings[e.code];
@@ -637,7 +573,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
e.preventDefault();
e.stopPropagation();
// Send the character directly to the terminal
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
@@ -657,12 +592,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 150); // Increased debounce for better stability
}, 150);
});
resizeObserver.observe(xtermRef.current);
// Show terminal immediately - better UX, no unnecessary delays
setVisible(true);
const readyFonts =
@@ -671,7 +605,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
: Promise.resolve();
readyFonts.then(() => {
// Fixed delay and authentication check - Linus principle: eliminate race conditions
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
@@ -681,23 +614,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.focus();
}
// Verify authentication before attempting WebSocket connection
const jwtToken = getCookie("jwt");
// DEBUG: Log only authentication failures
if (!jwtToken || jwtToken.trim() === "") {
console.debug("ReadyFonts Auth Check Failed:", {
isAuthenticated: isAuthenticated,
jwtPresent: !!jwtToken
});
}
if (!jwtToken || jwtToken.trim() === "") {
console.warn("WebSocket connection delayed - no authentication token");
console.warn(
"WebSocket connection delayed - no authentication token",
);
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
// Don't show toast here - let auth system handle it
return;
}
@@ -705,7 +630,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const rows = terminal.rows;
connectToHost(cols, rows);
}, 200); // Increased from 100ms to 200ms for auth stability
}, 200);
});
return () => {
@@ -728,7 +653,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {

View File

@@ -27,18 +27,14 @@ function AppContent() {
useEffect(() => {
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);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
// Check if user data is unlocked
if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate
console.warn("User data is locked - re-authentication required");
setIsAuthenticated(false);
setIsAdmin(false);
@@ -49,8 +45,7 @@ function AppContent() {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Check if this is a session expiration error
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");

View File

@@ -228,7 +228,9 @@ export function ServerConfig({
{versionInfo && !versionDismissed && (
<div className="space-y-2">
<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
variant="ghost"
size="sm"

View File

@@ -50,12 +50,10 @@ export function Homepage({
setDbError(null);
})
.catch((err) => {
console.error("Homepage: Error fetching user info:", err);
setIsAdmin(false);
setUsername(null);
setUserId(null);
// Check if this is a session expiration error
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
@@ -158,7 +156,7 @@ export function Homepage({
</div>
</div>
)}
<HomepageAlertManager userId={userId} loggedIn={loggedIn} />
</>
);

View File

@@ -198,10 +198,6 @@ export function HomepageAuth({
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()]);
setInternalLoggedIn(true);
@@ -245,10 +241,6 @@ export function HomepageAuth({
}
}
// ===== Legacy password reset functions (deprecated) =====
async function handleInitiatePasswordReset() {
@@ -312,12 +304,14 @@ export function HomepageAuth({
setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess"));
// Immediately redirect to login after successful reset
setTab("login");
resetPasswordState();
} catch (err: any) {
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
toast.error(
err?.response?.data?.error || t("errors.failedCompleteReset"),
);
} finally {
setResetLoading(false);
}
@@ -630,9 +624,7 @@ export function HomepageAuth({
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
Server
</Label>
<Label className="text-sm text-muted-foreground">Server</Label>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl}
</div>
@@ -711,373 +703,364 @@ export function HomepageAuth({
</div>
)}
{!internalLoggedIn &&
!authLoading &&
!totpRequired && (
<>
<div className="flex gap-2 mb-6">
{!internalLoggedIn && !authLoading && !totpRequired && (
<>
<div className="flex gap-2 mb-6">
<button
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
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("common.login")}
{t("auth.external")}
</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",
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{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>
</>
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
{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>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{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"
) : (
<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}
>
{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"
/>
</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}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
/>
</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 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>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
Server
</Label>
</div>
<LanguageSwitcher />
</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 className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,7 +1,12 @@
import React, { useState } from "react";
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
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 {
Sidebar,
@@ -68,19 +73,15 @@ interface SidebarProps {
async function handleLogout() {
try {
// Call backend logout endpoint to clear HttpOnly cookie and data session
await logoutUser();
// Clear any local storage (for Electron)
if (isElectron()) {
localStorage.removeItem("jwt");
}
// Reload the page to reset the application state
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
// Even if logout fails, reload the page to reset state
window.location.reload();
}
}

View File

@@ -57,14 +57,11 @@ interface LeftSidebarProps {
async function handleLogout() {
try {
// Call backend logout endpoint to clear HttpOnly cookie and data session
await logoutUser();
// Reload the page to reset the application state
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
// Even if logout fails, reload the page to reset state
window.location.reload();
}
}

View File

@@ -48,35 +48,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isVisibleRef.current = isVisible;
}, [isVisible]);
// Monitor authentication state - Linus principle: explicit state management
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
// Only update state if it actually changed - prevent unnecessary re-renders
setIsAuthenticated(prev => {
setIsAuthenticated((prev) => {
if (prev !== isAuth) {
console.debug("Mobile Auth State Changed:", {
from: prev,
to: isAuth,
jwtPresent: !!jwtToken,
timestamp: new Date().toISOString()
});
return isAuth;
}
return prev; // No change, don't trigger re-render
return prev;
});
};
// Check immediately
checkAuth();
// Reduced frequency - check every 5 seconds instead of every second
const authCheckInterval = setInterval(checkAuth, 5000);
return () => clearInterval(authCheckInterval);
}, []); // No dependencies - prevent infinite loop
}, []);
function hardRefresh() {
try {
@@ -139,8 +129,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
[terminal],
);
// Resize handling optimized to avoid conflicts - Linus principle: eliminate duplicate complexity
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
@@ -174,10 +162,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
else if (msg.type === "error")
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
else if (msg.type === "connected") {
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
} else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true;
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
terminal.writeln(
`\r\n[${msg.message || t("terminal.disconnected")}]`,
);
@@ -186,17 +174,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
});
ws.addEventListener("close", (event) => {
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
// Handle authentication errors (code 1008)
if (event.code === 1008) {
console.error("WebSocket authentication failed:", event.reason);
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
// Clear invalid JWT token
localStorage.removeItem("jwt");
// Don't attempt to reconnect on auth failure
return;
}
@@ -206,7 +190,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
});
ws.addEventListener("error", () => {
isConnectingRef.current = false; // Clear connecting state
isConnectingRef.current = false;
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
});
}
@@ -214,9 +198,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
if (!isAuthenticated) {
console.debug("Terminal setup delayed - waiting for authentication");
return;
}
@@ -231,7 +213,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
allowTransparency: true,
convertEol: true,
windowsMode: false,
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
@@ -271,7 +252,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 150); // Increased debounce for better stability
}, 150);
});
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
: Promise.resolve();
// Show terminal immediately - better UX for mobile
setVisible(true);
readyFonts.then(() => {
// Fixed delay and authentication check - Linus principle: eliminate race conditions
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
// Verify authentication before attempting WebSocket connection
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn("WebSocket connection delayed - no authentication token");
console.warn(
"WebSocket connection delayed - no authentication token",
);
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
// Don't show toast here - let auth system handle it
return;
}
@@ -325,27 +304,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
// Prevent duplicate connections - Linus principle: fail fast
if (isConnectingRef.current) {
console.debug("Skipping connection - already connecting");
return;
}
isConnectingRef.current = true;
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
console.log("Closing existing WebSocket connection before creating new one");
if (
webSocketRef.current &&
webSocketRef.current.readyState !== WebSocket.CLOSED
) {
webSocketRef.current.close();
}
// Clear existing ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Add JWT token as query parameter for authentication
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
setIsConnecting(true);
@@ -356,7 +332,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
wasDisconnectedBySSH.current = false;
setupWebSocketListeners(ws, cols, rows);
}, 200); // Increased from 100ms to 200ms for auth stability
}, 200);
});
return () => {
@@ -369,7 +345,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {

View File

@@ -180,8 +180,6 @@ export function HomepageAuth({
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()]);
setInternalLoggedIn(true);
@@ -214,7 +212,6 @@ export function HomepageAuth({
setIsAdmin(false);
setUsername(null);
setUserId(null);
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
if (err?.response?.data?.error?.includes("Database")) {
setDbError(t("errors.databaseConnection"));
} else {
@@ -286,12 +283,13 @@ export function HomepageAuth({
setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess"));
// Immediately redirect to login after successful reset
setTab("login");
resetPasswordState();
} catch (err: any) {
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
toast.error(
err?.response?.data?.error || t("errors.failedCompleteReset"),
);
} finally {
setResetLoading(false);
}
@@ -330,8 +328,6 @@ export function HomepageAuth({
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();
setInternalLoggedIn(true);
@@ -400,8 +396,6 @@ export function HomepageAuth({
setOidcLoading(true);
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()
.then((meRes) => {
setInternalLoggedIn(true);
@@ -429,7 +423,6 @@ export function HomepageAuth({
setIsAdmin(false);
setUsername(null);
setUserId(null);
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
window.history.replaceState(
{},
document.title,
@@ -528,336 +521,329 @@ export function HomepageAuth({
</div>
)}
{!internalLoggedIn &&
!authLoading &&
!totpRequired && (
<>
<div className="flex gap-2 mb-6">
<button
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
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>
{!internalLoggedIn && !authLoading && !totpRequired && (
<>
<div className="flex gap-2 mb-6">
<button
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",
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{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>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
</Button>
</>
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",
)}
{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");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</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-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}
/>
{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>
)}
<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();
}}
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{t("auth.resetPasswordButton")}
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
</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">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</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-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>
)}
<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>
);
}

View File

@@ -25,18 +25,14 @@ const AppContent: FC = () => {
useEffect(() => {
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);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
// Check if user data is unlocked
if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate
console.warn("User data is locked - re-authentication required");
setIsAuthenticated(false);
setIsAdmin(false);
@@ -47,8 +43,7 @@ const AppContent: FC = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Check if this is a session expiration error
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");

View File

@@ -60,7 +60,7 @@ async function handleLogout() {
try {
// Call backend logout endpoint to clear HttpOnly cookie and data session
await logoutUser();
// Reload the page to reset the application state
window.location.reload();
} catch (error) {

View File

@@ -86,17 +86,15 @@ export function DragIndicator({
)}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<div className="text-sm font-medium text-foreground mb-2">
{fileCount > 1 ? t("dragIndicator.batchDrag") : t("dragIndicator.dragToDesktop")}
{fileCount > 1
? t("dragIndicator.batchDrag")
: t("dragIndicator.dragToDesktop")}
</div>
{/* Status text */}
<div
className={cn(
"text-xs mb-3",
@@ -110,7 +108,6 @@ export function DragIndicator({
{getStatusText()}
</div>
{/* Progress bar */}
{(isDownloading || isDragging) && !error && (
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
<div
@@ -123,14 +120,12 @@ export function DragIndicator({
</div>
)}
{/* Progress percentage */}
{(isDownloading || isDragging) && !error && (
<div className="text-xs text-muted-foreground">
{progress.toFixed(0)}%
</div>
)}
{/* Drag hint */}
{isDragging && !error && (
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
<Download className="w-3 h-3" />
@@ -140,7 +135,6 @@ export function DragIndicator({
</div>
</div>
{/* Background with animation effect */}
{isDragging && !error && (
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
)}

View File

@@ -32,7 +32,6 @@ export function useDragToDesktop({
error: null,
});
// Check if running in Electron environment
const isElectron = () => {
return (
typeof window !== "undefined" &&
@@ -41,13 +40,13 @@ export function useDragToDesktop({
);
};
// Drag single file to desktop
const dragFileToDesktop = useCallback(
async (file: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error = "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);
onError?.(error);
return false;
@@ -68,7 +67,6 @@ export function useDragToDesktop({
error: null,
}));
// Download file content
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
@@ -77,7 +75,6 @@ export function useDragToDesktop({
setState((prev) => ({ ...prev, progress: 50 }));
// Create temporary file
const tempResult = await window.electronAPI.createTempFile({
fileName: file.name,
content: response.content,
@@ -85,12 +82,13 @@ export function useDragToDesktop({
});
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 }));
// Start dragging
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: file.name,
@@ -108,7 +106,6 @@ export function useDragToDesktop({
onSuccess?.();
// Delayed cleanup of temporary file (give user time to complete drag)
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
@@ -117,7 +114,7 @@ export function useDragToDesktop({
isDownloading: false,
progress: 0,
}));
}, 10000); // Cleanup after 10 seconds
}, 10000);
return true;
} catch (error: any) {
@@ -143,13 +140,13 @@ export function useDragToDesktop({
[sshSessionId, sshHost],
);
// Drag multiple files to desktop (batch operation)
const dragFilesToDesktop = useCallback(
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error = "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);
onError?.(error);
return false;
@@ -175,7 +172,6 @@ export function useDragToDesktop({
error: null,
}));
// Batch download files
const downloadPromises = fileList.map((file) =>
downloadSSHFile(sshSessionId, file.path),
);
@@ -183,7 +179,6 @@ export function useDragToDesktop({
const responses = await Promise.all(downloadPromises);
setState((prev) => ({ ...prev, progress: 40 }));
// Create temporary folder structure
const folderName = `Files_${Date.now()}`;
const filesData = fileList.map((file, index) => ({
relativePath: file.name,
@@ -197,12 +192,13 @@ export function useDragToDesktop({
});
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 }));
// Start dragging folder
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: folderName,
@@ -220,7 +216,6 @@ export function useDragToDesktop({
onSuccess?.();
// Delayed cleanup of temporary folder
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
@@ -229,8 +224,7 @@ export function useDragToDesktop({
isDownloading: false,
progress: 0,
}));
}, 15000); // Cleanup after 15 seconds
}, 15000);
return true;
} catch (error: any) {
console.error("Failed to batch drag to desktop:", error);
@@ -255,13 +249,13 @@ export function useDragToDesktop({
[sshSessionId, sshHost, dragFileToDesktop],
);
// Drag folder to desktop
const dragFolderToDesktop = useCallback(
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error = "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);
onError?.(error);
return false;
@@ -278,9 +272,6 @@ export function useDragToDesktop({
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;
},
[sshSessionId, sshHost],

View File

@@ -37,7 +37,6 @@ export function useDragToSystemDesktop({
options: DragToSystemOptions;
} | null>(null);
// Directory memory functionality
const getLastSaveDirectory = async () => {
try {
if ("indexedDB" in window) {
@@ -60,9 +59,7 @@ export function useDragToSystemDesktop({
};
});
}
} catch (error) {
console.log("Unable to get last save directory:", error);
}
} catch (error) {}
return null;
};
@@ -78,19 +75,15 @@ export function useDragToSystemDesktop({
store.put({ handle: dirHandle }, "lastSaveDir");
};
}
} catch (error) {
console.log("Unable to save directory record:", error);
}
} catch (error) {}
};
// Check File System Access API support
const isFileSystemAPISupported = () => {
return "showSaveFilePicker" in window;
};
// Check if drag has left window boundaries
const isDraggedOutsideWindow = (e: DragEvent) => {
const margin = 50; // Increase tolerance margin
const margin = 50;
return (
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
@@ -99,14 +92,12 @@ export function useDragToSystemDesktop({
);
};
// Create file blob
const createFileBlob = async (file: FileItem): Promise<Blob> => {
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error(`Unable to get content for file ${file.name}`);
}
// Convert base64 to blob
const binaryString = atob(response.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
@@ -116,9 +107,7 @@ export function useDragToSystemDesktop({
return new Blob([bytes]);
};
// Create ZIP file (for multi-file download)
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 zip = new JSZip();
@@ -130,8 +119,6 @@ export function useDragToSystemDesktop({
return await zip.generateAsync({ type: "blob" });
};
// Fallback solution: traditional download
const fallbackDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -143,7 +130,6 @@ export function useDragToSystemDesktop({
URL.revokeObjectURL(url);
};
// Handle drag to system desktop
const handleDragToSystem = useCallback(
async (files: FileItem[], options: DragToSystemOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
@@ -155,7 +141,6 @@ export function useDragToSystemDesktop({
return false;
}
// Filter out file types
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "Only files can be dragged to desktop";
@@ -172,12 +157,9 @@ export function useDragToSystemDesktop({
error: null,
}));
// Determine file name first (synchronously)
const fileName = fileList.length === 1
? fileList[0].name
: `files_${Date.now()}.zip`;
const fileName =
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
// For File System Access API, get the file handle FIRST to preserve user gesture
let fileHandle: any = null;
if (isFileSystemAPISupported()) {
try {
@@ -188,14 +170,21 @@ export function useDragToSystemDesktop({
{
description: "Files",
accept: {
"*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
"*/*": [
".txt",
".jpg",
".png",
".pdf",
".zip",
".tar",
".gz",
],
},
},
],
});
} catch (error: any) {
if (error.name === "AbortError") {
// User cancelled
setState((prev) => ({
...prev,
isDownloading: false,
@@ -207,32 +196,28 @@ export function useDragToSystemDesktop({
}
}
// Now create the blob (after getting file handle)
let blob: Blob;
if (fileList.length === 1) {
// Single file
blob = await createFileBlob(fileList[0]);
setState((prev) => ({ ...prev, progress: 70 }));
} else {
// Package multiple files into ZIP
blob = await createZipBlob(fileList);
setState((prev) => ({ ...prev, progress: 70 }));
}
setState((prev) => ({ ...prev, progress: 90 }));
// Save the file
if (fileHandle) {
// Use File System Access API with pre-obtained handle
await saveLastDirectory(fileHandle);
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} else {
// Fallback to traditional download
fallbackDownload(blob, fileName);
if (enableToast) {
toast.info("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?.();
// Reset state
setTimeout(() => {
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
}, 1000);
return true;
} catch (error: any) {
console.error("Failed to drag to desktop:", error);
const errorMessage = error.message || "Save failed";
setState((prev) => ({
@@ -276,7 +259,6 @@ export function useDragToSystemDesktop({
[sshSessionId],
);
// Start dragging (record drag data)
const startDragToSystem = useCallback(
(files: FileItem[], options: DragToSystemOptions = {}) => {
dragDataRef.current = { files, options };
@@ -285,27 +267,22 @@ export function useDragToSystemDesktop({
[],
);
// End drag detection
const handleDragEnd = useCallback(
(e: DragEvent) => {
if (!dragDataRef.current) return;
const { files, options } = dragDataRef.current;
// Check if dragged outside window
if (isDraggedOutsideWindow(e)) {
// Execute immediately to preserve user gesture context for showSaveFilePicker
handleDragToSystem(files, options);
}
// Clean up drag state
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false }));
},
[handleDragToSystem],
);
// Cancel dragging
const cancelDragToSystem = useCallback(() => {
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false, error: null }));
@@ -317,6 +294,6 @@ export function useDragToSystemDesktop({
startDragToSystem,
handleDragEnd,
cancelDragToSystem,
handleDragToSystem, // Direct call version
handleDragToSystem,
};
}

View File

@@ -115,8 +115,6 @@ export function setCookie(name: string, value: string, days = 7): void {
if (isElectron()) {
localStorage.setItem(name, value);
} 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();
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 encodedToken =
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
// Decode the token since setCookie uses encodeURIComponent
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
return token;
}
@@ -271,33 +268,26 @@ function createApiInstance(
}
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 isSessionExpired = errorCode === "SESSION_EXPIRED";
// Clear authentication state
if (isElectron()) {
localStorage.removeItem("jwt");
} else {
// For web, the secure HttpOnly cookie will be cleared by the backend
// We can't clear HttpOnly cookies from JavaScript
localStorage.removeItem("jwt");
}
// If session expired, show notification and reload page
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
import("sonner").then(({ toast }) => {
toast.warning("Session expired - please log in again");
});
// Trigger a page reload to redirect to login
setTimeout(() => window.location.reload(), 100);
}
}
return Promise.reject(error);
},
);
@@ -441,7 +431,6 @@ function getApiUrl(path: string, defaultPort: number): string {
}
}
// Initialize API instances
function initializeApiInstances() {
// SSH Host Management API (port 30001)
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
@@ -477,7 +466,6 @@ export let statsApi: AxiosInstance;
// Authentication API (port 30001)
export let authApi: AxiosInstance;
// Initialize API instances immediately
initializeApiInstances();
function updateApiInstances() {
@@ -488,7 +476,6 @@ function updateApiInstances() {
initializeApiInstances();
// Make configuredServerUrl available globally for components that need it
(window as any).configuredServerUrl = configuredServerUrl;
systemLogger.success("All API instances updated successfully", {
@@ -587,7 +574,6 @@ function handleApiError(error: unknown, operation: string): never {
"SERVER_ERROR",
);
} else if (status === 0) {
// Check if this is a "no server configured" error
if (url.includes("no-server-configured")) {
apiLogger.error(
`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> {
try {
const response = await sshHostApi.post("/autostart/enable", { sshConfigId });
const response = await sshHostApi.post("/autostart/enable", {
sshConfigId,
});
return response.data;
} catch (error) {
handleApiError(error, "enable autostart");
@@ -806,7 +794,7 @@ export async function enableAutoStart(sshConfigId: number): Promise<any> {
export async function disableAutoStart(sshConfigId: number): Promise<any> {
try {
const response = await sshHostApi.delete("/autostart/disable", {
data: { sshConfigId }
data: { sshConfigId },
});
return response.data;
} catch (error) {
@@ -1072,7 +1060,7 @@ export async function listSSHFiles(
return response.data || { files: [], path };
} catch (error) {
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;
} catch (error: any) {
// Preserve fileNotFound information for 404 errors
if (error.response?.status === 404) {
const customError = new Error("File not found");
(customError as any).response = error.response;
(customError as any).isFileNotFound = error.response.data?.fileNotFound || true;
(customError as any).isFileNotFound =
error.response.data?.fileNotFound || true;
throw customError;
}
handleApiError(error, "read SSH file");
@@ -1268,7 +1256,7 @@ export async function copySSHItem(
userId,
},
{
timeout: 60000, // 60 second timeout as file copying may take longer
timeout: 60000,
},
);
return response.data;
@@ -1308,15 +1296,19 @@ export async function moveSSHItem(
userId?: string,
): Promise<any> {
try {
const response = await fileManagerApi.put("/ssh/moveItem", {
sessionId,
oldPath,
newPath,
hostId,
userId,
}, {
timeout: 60000, // 60 second timeout for move operations
});
const response = await fileManagerApi.put(
"/ssh/moveItem",
{
sessionId,
oldPath,
newPath,
hostId,
userId,
},
{
timeout: 60000,
},
);
return response.data;
} catch (error) {
handleApiError(error, "move SSH item");
@@ -1374,7 +1366,6 @@ export async function removeRecentFile(
}
}
// Pinned Files
export async function getPinnedFiles(hostId: number): Promise<any> {
try {
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> {
try {
const response = await authApi.get("/ssh/file_manager/shortcuts", {
@@ -1524,10 +1514,8 @@ export async function loginUser(
): Promise<AuthResponse> {
try {
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 {
token: "cookie-based", // Placeholder since token is in HttpOnly cookie
token: "cookie-based",
success: response.data.success,
is_admin: response.data.is_admin,
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 {
const response = await authApi.post("/users/logout");
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 {
const response = await authApi.post("/users/unlock-data", { password });
return response.data;
@@ -1824,9 +1817,7 @@ export async function getUserAlerts(): Promise<{ alerts: any[] }> {
}
}
export async function dismissAlert(
alertId: string,
): Promise<any> {
export async function dismissAlert(alertId: string): Promise<any> {
try {
const response = await authApi.post("/alerts/dismiss", { alertId });
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> {
try {
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(
hostId: number,
credentialId: number,
@@ -1971,7 +1960,6 @@ export async function applyCredentialToHost(
}
}
// Remove credential from SSH host
export async function removeCredentialFromHost(hostId: number): Promise<any> {
try {
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(
hostId: number,
credentialName: string,