This commit is contained in:
Baobhan Sith
2025-04-20 15:23:58 +08:00
parent 1160f8a514
commit 77cd9272ba
31 changed files with 2781 additions and 2113 deletions
+213
View File
@@ -0,0 +1,213 @@
// packages/backend/src/database/connection.ts
import sqlite3, { OPEN_READWRITE, OPEN_CREATE } from 'sqlite3'; // Import flags
import path from 'path';
import fs from 'fs';
import * as schema from './schema';
// Import the table definitions registry instead of individual repositories here
import { tableDefinitions } from './schema.registry';
// presetTerminalThemes might still be needed if passed directly, but likely handled in registry now
// import { presetTerminalThemes } from '../config/preset-themes-definition';
// --- Revert to original path and filename ---
// 使用 process.cwd() 获取项目根目录,然后拼接路径,确保路径一致性
console.log('[Connection CWD]', process.cwd()); // 添加 CWD 日志
const dbDir = path.join(process.cwd(), 'data'); // Correct path relative to CWD (packages/backend)
const dbFilename = 'nexus-terminal.db'; // Revert to original filename
const dbPath = path.join(dbDir, dbFilename);
console.log(`[DB Path] Determined database directory: ${dbDir}`);
console.log(`[DB Path] Determined database file path: ${dbPath}`);
// Add logging before checking/creating directory
console.log(`[DB FS] Checking existence of directory: ${dbDir}`);
if (!fs.existsSync(dbDir)) {
console.log(`[DB FS] Directory does not exist. Attempting to create: ${dbDir}`);
try {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`[DB FS] Directory successfully created: ${dbDir}`);
} catch (mkdirErr: any) {
console.error(`[DB FS] Failed to create directory ${dbDir}:`, mkdirErr.message);
// Consider throwing error here to prevent proceeding if directory creation fails
throw new Error(`Failed to create database directory: ${mkdirErr.message}`);
}
} else {
console.log(`[DB FS] Directory already exists: ${dbDir}`);
}
const verboseSqlite3 = sqlite3.verbose();
let dbInstancePromise: Promise<sqlite3.Database> | null = null;
// --- Promisified Database Operations ---
interface RunResult {
lastID: number;
changes: number;
}
/**
* Promisified version of db.run(). Resolves with { lastID, changes }.
*/
export const runDb = (db: sqlite3.Database, sql: string, params: any[] = []): Promise<RunResult> => {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err: Error | null) { // Use function() to access this
if (err) {
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
reject(err);
} else {
// 'this' context provides lastID and changes for INSERT/UPDATE/DELETE
resolve({ lastID: this.lastID, changes: this.changes });
}
});
});
};
/**
* Promisified version of db.get(). Resolves with the row found, or undefined.
*/
export const getDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T | undefined> => {
return new Promise((resolve, reject) => {
db.get(sql, params, (err: Error | null, row: T) => { // Add type annotation for row
if (err) {
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
reject(err);
} else {
resolve(row); // row will be undefined if not found
}
});
});
};
/**
* Promisified version of db.all(). Resolves with an array of rows found.
*/
export const allDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T[]> => {
return new Promise((resolve, reject) => {
db.all(sql, params, (err: Error | null, rows: T[]) => { // Add type annotation for rows
if (err) {
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
reject(err);
} else {
resolve(rows); // rows will be an empty array if no matches
}
});
});
};
/**
* Executes the database initialization sequence: creates all tables, inserts preset/default data.
* Now returns a Promise that resolves when all initializations are complete.
* @param db The database instance
*/
const runDatabaseInitializations = async (db: sqlite3.Database): Promise<void> => {
console.log('[DB Init] 开始数据库初始化序列...');
try {
// 1. Enable foreign key constraints
await runDb(db, 'PRAGMA foreign_keys = ON;'); // Use promisified runDb
console.log('[DB Init] 外键约束已启用。');
// 2. Create tables and run initializations based on the registry
for (const tableDef of tableDefinitions) {
await runDb(db, tableDef.sql); // Create table (IF NOT EXISTS)
console.log(`[DB Init] ${tableDef.name} 表已存在或已创建。`);
if (tableDef.init) {
// Pass the db instance to the init function
await tableDef.init(db);
}
}
// Migrations (if any) would run after initial schema setup
// import { runMigrations } from './migrations';
// await runMigrations(db);
// console.log('[DB Init] 迁移检查完成。');
console.log('[DB Init] 数据库初始化序列成功完成。');
} catch (error) {
console.error('[DB Init] 数据库初始化序列失败:', error);
// Propagate the error to stop the application startup in index.ts
throw error;
}
};
/**
* Gets the database instance. Initializes the connection and runs initializations if not already done.
* Returns a Promise that resolves with the database instance once ready.
*/
// Renamed original getDb to getDbInstance to avoid confusion with the promisified getDb helper
export const getDbInstance = (): Promise<sqlite3.Database> => {
if (!dbInstancePromise) {
dbInstancePromise = new Promise((resolve, reject) => {
// Remove connectionFailed flag and double check logic
// Add logging before attempting connection
console.log(`[DB Connection] Attempting to connect/open database file with explicit create flag: ${dbPath}`);
// Explicitly add OPEN_READWRITE and OPEN_CREATE flags
const db = new verboseSqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, async (err) => { // Mark callback as async
// --- Strict Error Check FIRST ---
if (err) {
console.error(`[DB Connection] Error opening database file ${dbPath}:`, err.message);
// connectionFailed = true; // Remove flag setting
dbInstancePromise = null; // Reset promise on error
reject(err); // Reject the main promise
return; // Explicitly return
}
// --- End Strict Error Check ---
// Remove Double Check Flag logic
// If no error, proceed with success logging and initialization
console.log(`[DB Connection] Successfully connected to SQLite database: ${dbPath}`);
try {
// Wait for initializations to complete
await runDatabaseInitializations(db);
console.log('[DB] Database initialization complete. Ready.');
resolve(db); // Resolve the main promise with the db instance
} catch (initError) {
console.error('[DB] Initialization failed after connection, closing connection...');
// connectionFailed = true; // Remove flag setting
dbInstancePromise = null; // Reset promise on error
db.close((closeErr) => {
if (closeErr) console.error('[DB] Error closing connection after init failure:', closeErr.message);
reject(initError); // Reject with the initialization error
});
// process.exit(1); // Consider exiting on init failure
}
});
});
}
return dbInstancePromise;
};
// Graceful shutdown remains the same, but it might need access to the resolved instance
// Consider a way to get the instance if needed during shutdown, e.g., a global variable set after promise resolution.
// For now, it checks the promise state indirectly.
process.on('SIGINT', async () => { // Mark as async if needed
if (dbInstancePromise) {
console.log('[DB] 收到 SIGINT,尝试关闭数据库连接...');
try {
// We need the actual instance, not the promise, to close
// Let's assume if the promise exists, we try to resolve it to get the instance
const db = await dbInstancePromise;
db.close((err) => {
if (err) {
console.error('[DB] 关闭数据库时出错:', err.message);
} else {
console.log('[DB] 数据库连接已关闭。');
}
process.exit(err ? 1 : 0);
});
} catch (error) {
console.error('[DB] 获取数据库实例以关闭时出错 (可能初始化失败):', error);
process.exit(1);
}
} else {
console.log('[DB] 收到 SIGINT,但数据库连接从未初始化或已失败。');
process.exit(0);
}
});
// Note: We now export getDbInstance (the promise for the connection)
// and the helper functions runDb, getDb, allDb.
// Files needing the db instance will call `const db = await getDbInstance();`
// and then use `await runDb(db, ...)` etc.
@@ -0,0 +1,24 @@
// packages/backend/src/migrations.ts
import { Database } from 'sqlite3';
// import { getDb } from './database'; // 可能不再需要直接从这里获取 db
/**
* 运行数据库迁移。
* 注意:此函数目前为空,仅作为未来迁移的占位符。
* 数据库的初始模式创建在 database.ts 的初始化逻辑中处理。
* @param db 数据库实例
*/
export const runMigrations = (db: Database): Promise<void> => {
return new Promise<void>((resolve) => {
console.log('[Migrations] 检查数据库迁移(当前无操作)。');
// 在这里添加未来的迁移逻辑,例如:
// db.serialize(() => {
// db.run("ALTER TABLE users ADD COLUMN last_login INTEGER;", (err) => { ... });
// // 更多迁移步骤...
// });
resolve(); // 立即解决,因为没有迁移要运行
});
};
// 可以保留一个默认导出或根据需要移除
// export default runMigrations;
@@ -0,0 +1,99 @@
import { Database } from 'sqlite3';
import * as schemaSql from './schema';
import * as appearanceRepository from '../repositories/appearance.repository';
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
import { presetTerminalThemes } from '../config/preset-themes-definition';
import { runDb } from './connection'; // Import runDb for init functions
/**
* Interface describing a database table definition for initialization.
*/
export interface TableDefinition {
name: string;
sql: string;
init?: (db: Database) => Promise<void>; // Optional initialization function
}
// --- Initialization Functions ---
/**
* Initializes default settings in the settings table.
*/
const initSettingsTable = async (db: Database): Promise<void> => {
const defaultSettings = [
{ key: 'ipWhitelistEnabled', value: 'false' },
{ key: 'ipWhitelist', value: '' }
];
for (const setting of defaultSettings) {
// Use INSERT OR IGNORE to avoid errors if settings already exist
await runDb(db, "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", [setting.key, setting.value]);
}
console.log('[DB Init] 默认 settings 初始化检查完成。');
};
/**
* Initializes preset terminal themes.
* Assumes terminalThemeRepository.initializePresetThemes might need the db instance.
*/
const initTerminalThemesTable = async (db: Database): Promise<void> => {
// Pass the db instance to the repository function
// Note: This might require modifying initializePresetThemes if it doesn't accept db
await terminalThemeRepository.initializePresetThemes(db, presetTerminalThemes);
console.log('[DB Init] 预设主题初始化检查完成。');
};
/**
* Ensures default appearance settings exist.
* Assumes appearanceRepository.ensureDefaultSettingsExist might need the db instance.
*/
const initAppearanceSettingsTable = async (db: Database): Promise<void> => {
// Pass the db instance to the repository function
// Note: This might require modifying ensureDefaultSettingsExist if it doesn't accept db
await appearanceRepository.ensureDefaultSettingsExist(db);
console.log('[DB Init] 外观设置初始化检查完成。');
};
// --- Table Definitions Registry ---
/**
* Array containing definitions for all tables to be created and initialized.
* The order might matter if there are strict foreign key dependencies without ON DELETE/UPDATE clauses,
* but CREATE IF NOT EXISTS makes it generally safe. Initialization order might also matter.
*/
export const tableDefinitions: TableDefinition[] = [
// Core settings and logs first
{
name: 'settings',
sql: schemaSql.createSettingsTableSQL,
init: initSettingsTable
},
{ name: 'audit_logs', sql: schemaSql.createAuditLogsTableSQL },
{ name: 'api_keys', sql: schemaSql.createApiKeysTableSQL },
{ name: 'passkeys', sql: schemaSql.createPasskeysTableSQL },
{ name: 'notification_settings', sql: schemaSql.createNotificationSettingsTableSQL },
{ name: 'users', sql: schemaSql.createUsersTableSQL },
// Features like proxies, connections, tags
{ name: 'proxies', sql: schemaSql.createProxiesTableSQL },
{ name: 'connections', sql: schemaSql.createConnectionsTableSQL }, // Depends on proxies
{ name: 'tags', sql: schemaSql.createTagsTableSQL },
{ name: 'connection_tags', sql: schemaSql.createConnectionTagsTableSQL }, // Depends on connections, tags
// Other utilities
{ name: 'ip_blacklist', sql: schemaSql.createIpBlacklistTableSQL },
{ name: 'command_history', sql: schemaSql.createCommandHistoryTableSQL },
{ name: 'quick_commands', sql: schemaSql.createQuickCommandsTableSQL },
// Appearance related tables (often depend on others or have init logic)
{
name: 'terminal_themes',
sql: schemaSql.createTerminalThemesTableSQL,
init: initTerminalThemesTable
},
{
name: 'appearance_settings',
sql: schemaSql.createAppearanceSettingsTableSQL,
init: initAppearanceSettingsTable
}, // Depends on terminal_themes
];
+190
View File
@@ -0,0 +1,190 @@
// packages/backend/src/schema.ts
export const createSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createAuditLogsTableSQL = `
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
action_type TEXT NOT NULL,
details TEXT NULL
);
`;
export const createApiKeysTableSQL = `
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hashed_key TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
`;
export const createPasskeysTableSQL = `
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
public_key TEXT NOT NULL, -- Base64URL encoded
counter INTEGER NOT NULL,
transports TEXT, -- JSON array as string, e.g., '["internal", "usb"]'
name TEXT, -- User-provided name for the key
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createNotificationSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS notification_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_type TEXT NOT NULL CHECK(channel_type IN ('webhook', 'email', 'telegram')),
name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT false,
config TEXT NOT NULL DEFAULT '{}', -- JSON string for channel-specific config
enabled_events TEXT NOT NULL DEFAULT '[]', -- JSON array of event names
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
two_factor_secret TEXT NULL, -- 添加 2FA 密钥列,允许为空
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createProxiesTableSQL = `
CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('SOCKS5', 'HTTP')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NULL,
auth_method TEXT NOT NULL DEFAULT 'none' CHECK(auth_method IN ('none', 'password', 'key')),
encrypted_password TEXT NULL,
encrypted_private_key TEXT NULL,
encrypted_passphrase TEXT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(name, type, host, port)
);
`;
export const createConnectionsTableSQL = `
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NULL, -- 允许 name 为空
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
encrypted_password TEXT NULL,
encrypted_private_key TEXT NULL,
encrypted_passphrase TEXT NULL,
proxy_id INTEGER NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_connected_at INTEGER NULL,
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL
);
`;
export const createTagsTableSQL = `
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createConnectionTagsTableSQL = `
CREATE TABLE IF NOT EXISTS connection_tags (
connection_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (connection_id, tag_id),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
`;
export const createIpBlacklistTableSQL = `
CREATE TABLE IF NOT EXISTS ip_blacklist (
ip TEXT PRIMARY KEY NOT NULL,
attempts INTEGER NOT NULL DEFAULT 1,
last_attempt_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
blocked_until INTEGER NULL -- 封禁截止时间戳 (秒),NULL 表示未封禁或永久封禁 (根据逻辑决定)
);
`;
export const createCommandHistoryTableSQL = `
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createQuickCommandsTableSQL = `
CREATE TABLE IF NOT EXISTS quick_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NULL, -- 名称可选
command TEXT NOT NULL, -- 指令必选
usage_count INTEGER NOT NULL DEFAULT 0, -- 使用频率
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
// 从 database.ts 移动过来的,保持一致性
export const createTerminalThemesTableSQL = `
CREATE TABLE IF NOT EXISTS terminal_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
theme_type TEXT NOT NULL CHECK(theme_type IN ('preset', 'user')),
foreground TEXT,
background TEXT,
cursor TEXT,
cursor_accent TEXT,
selection_background TEXT,
black TEXT,
red TEXT,
green TEXT,
yellow TEXT,
blue TEXT,
magenta TEXT,
cyan TEXT,
white TEXT,
bright_black TEXT,
bright_red TEXT,
bright_green TEXT,
bright_yellow TEXT,
bright_blue TEXT,
bright_magenta TEXT,
bright_cyan TEXT,
bright_white TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createAppearanceSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS appearance_settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;