diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 7a2111de..2f2dc20b 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -576,19 +576,30 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); `); - // Export current user only (exclude sensitive fields like password_hash) + // Export current user only (replace sensitive password_hash with placeholder) const userRecord = user[0]; const insertUser = exportDb.prepare(` - INSERT INTO users (id, username, is_admin, is_oidc, oidc_identifier, totp_enabled) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); insertUser.run( userRecord.id, userRecord.username, + "[EXPORTED_USER_NO_PASSWORD]", // Replace password hash with placeholder userRecord.is_admin ? 1 : 0, userRecord.is_oidc ? 1 : 0, userRecord.oidc_identifier || null, - userRecord.totp_enabled ? 1 : 0 + userRecord.client_id || null, + userRecord.client_secret || null, + userRecord.issuer_url || null, + userRecord.authorization_url || null, + userRecord.token_url || null, + userRecord.identifier_path || null, + userRecord.name_path || null, + userRecord.scopes || null, + userRecord.totp_secret || null, + userRecord.totp_enabled ? 1 : 0, + userRecord.totp_backup_codes || null ); // Export SSH hosts (decrypted) @@ -894,7 +905,7 @@ app.post("/database/import", authenticateJWT, upload.single("file"), async (req, autostartPassword: host.autostart_password, autostartKey: host.autostart_key, autostartKeyPassword: host.autostart_key_password, - credentialId: host.credential_id, + credentialId: null, // Reset credential references during import - user can reassign enableTerminal: Boolean(host.enable_terminal), enableTunnel: Boolean(host.enable_tunnel), tunnelConnections: host.tunnel_connections, diff --git a/src/backend/database/db/old-index.ts.bak b/src/backend/database/db/old-index.ts.bak new file mode 100644 index 00000000..7098d0b4 --- /dev/null +++ b/src/backend/database/db/old-index.ts.bak @@ -0,0 +1,600 @@ +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import * as schema from "./schema.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { UserDatabaseManager } from "../../utils/user-database-manager.js"; + +// Global database manager instance +const databaseManager = UserDatabaseManager.getInstance(); + +/** + * Initialize database system - simplified for user-based architecture + */ +async function initializeDatabase(): Promise { + 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>; + +// Export database connection getter function to avoid undefined access +export function getDb(): ReturnType> { + 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 }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4068b963..ff979b5a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -488,7 +488,11 @@ "createBackup": "Create Backup", "exportImport": "Export/Import", "export": "Export", - "import": "Import" + "import": "Import", + "passwordRequired": "Password required", + "confirmExport": "Confirm Export", + "exportDescription": "Export SSH hosts and credentials as SQLite file", + "importDescription": "Import SQLite file with incremental merge (skips duplicates)" }, "hosts": { "title": "Host Manager", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index b2860f90..16d7ee7d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -474,7 +474,11 @@ "createBackup": "创建备份", "exportImport": "导出/导入", "export": "导出", - "import": "导入" + "import": "导入", + "passwordRequired": "密码为必填项", + "confirmExport": "确认导出", + "exportDescription": "将SSH主机和凭据导出为SQLite文件", + "importDescription": "导入SQLite文件并进行增量合并(跳过重复项)" }, "hosts": { "title": "主机管理",