import Database from "better-sqlite3"; import fs from "node:fs"; import path from "node:path"; import { encryptText } from "@/lib/crypto"; import { getEnv } from "@/lib/env"; import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password"; const MIGRATIONS = [ ` CREATE TABLE IF NOT EXISTS admins ( id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS admin_sessions ( id TEXT PRIMARY KEY, admin_id INTEGER NOT NULL, session_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS hy2_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, auth_id TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, expires_at TEXT, traffic_limit_bytes INTEGER, used_tx_bytes INTEGER NOT NULL DEFAULT 0, used_rx_bytes INTEGER NOT NULL DEFAULT 0, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS hy2_nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, auth_token TEXT NOT NULL, traffic_stats_url TEXT NOT NULL, traffic_stats_secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, poll_interval_seconds INTEGER NOT NULL DEFAULT 15, last_polled_at TEXT, last_sync_ok_at TEXT, last_error_at TEXT, last_error_message TEXT, last_online_users INTEGER NOT NULL DEFAULT 0, last_stream_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS traffic_ledgers ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER NOT NULL, user_id INTEGER, auth_id TEXT NOT NULL, tx_bytes INTEGER NOT NULL, rx_bytes INTEGER NOT NULL, window_started_at TEXT NOT NULL, window_ended_at TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS node_sync_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT NOT NULL, ok INTEGER NOT NULL, traffic_entries INTEGER NOT NULL DEFAULT 0, online_users INTEGER NOT NULL DEFAULT 0, stream_count INTEGER NOT NULL DEFAULT 0, kicked_auth_ids TEXT NOT NULL DEFAULT '', error_message TEXT, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS auth_audits ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER, user_id INTEGER, auth_id TEXT, username TEXT, addr TEXT, requested_tx INTEGER, ok INTEGER NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS node_user_presence ( node_id INTEGER NOT NULL, user_id INTEGER, auth_id TEXT NOT NULL, connections INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (node_id, auth_id), FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_hy2_users_auth_id ON hy2_users(auth_id); CREATE INDEX IF NOT EXISTS idx_hy2_users_username ON hy2_users(username); CREATE INDEX IF NOT EXISTS idx_hy2_nodes_enabled ON hy2_nodes(enabled); CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id); CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_created_at ON traffic_ledgers(created_at); CREATE INDEX IF NOT EXISTS idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_auth_audits_created_at ON auth_audits(created_at DESC); `, ` ALTER TABLE hy2_users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0; ALTER TABLE hy2_users ADD COLUMN auth_secret_encrypted TEXT; ALTER TABLE hy2_users ADD COLUMN subscription_token TEXT; ALTER TABLE hy2_users ADD COLUMN subscription_label TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, session_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); ALTER TABLE hy2_nodes ADD COLUMN server_host TEXT NOT NULL DEFAULT ''; ALTER TABLE hy2_nodes ADD COLUMN server_port INTEGER NOT NULL DEFAULT 443; ALTER TABLE hy2_nodes ADD COLUMN client_name TEXT NOT NULL DEFAULT ''; ALTER TABLE hy2_nodes ADD COLUMN bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30; ALTER TABLE hy2_nodes ADD COLUMN bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30; ALTER TABLE hy2_nodes ADD COLUMN skip_cert_verify INTEGER NOT NULL DEFAULT 0; ALTER TABLE hy2_nodes ADD COLUMN sni TEXT; ALTER TABLE hy2_nodes ADD COLUMN traffic_stats_listen TEXT NOT NULL DEFAULT ':9999'; CREATE UNIQUE INDEX IF NOT EXISTS idx_hy2_users_subscription_token ON hy2_users(subscription_token); CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); `, ` ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443'; `, ]; declare global { var __hy2PanelDb: Database.Database | undefined; var __hy2PanelDbInit: Promise | undefined; } function createDbInstance() { const env = getEnv(); const filePath = path.isAbsolute(env.DATABASE_PATH) ? env.DATABASE_PATH : path.join(/* turbopackIgnore: true */ process.cwd(), env.DATABASE_PATH); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const db = new Database(filePath); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); return db; } async function initializeDb(db: Database.Database) { const currentVersion = db.pragma("user_version", { simple: true }) as number; for (let index = currentVersion; index < MIGRATIONS.length; index += 1) { db.exec(MIGRATIONS[index]); db.pragma(`user_version = ${index + 1}`); } const env = getEnv(); const now = new Date().toISOString(); const passwordHash = await hashPassword(env.ADMIN_PASSWORD); db.prepare( ` INSERT INTO admins (id, username, password_hash, created_at, updated_at) VALUES (1, @username, @passwordHash, @now, @now) ON CONFLICT(id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, updated_at = excluded.updated_at `, ).run({ username: env.ADMIN_USERNAME, passwordHash, now, }); const templatePath = path.join(process.cwd(), "app", "config", "config.yaml"); const defaultTemplate = fs.readFileSync(templatePath, "utf8"); const settings = db.prepare( ` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO NOTHING `, ); settings.run("clash_template", defaultTemplate, now); settings.run("extra_proxy_groups", "", now); settings.run("profile_name", "FeieProxy", now); settings.run("proxy_groups_json", "[]", now); const existingAdminUser = db .prepare( ` SELECT id, auth_id, subscription_token FROM hy2_users WHERE is_admin = 1 OR username = ? ORDER BY is_admin DESC, id ASC LIMIT 1 `, ) .get(env.ADMIN_USERNAME) as | { id: number; auth_id: string; subscription_token: string | null } | undefined; if (existingAdminUser) { db.prepare( ` UPDATE hy2_users SET username = @username, password_hash = @passwordHash, auth_secret_encrypted = @secret, is_admin = 1, enabled = 1, expires_at = NULL, traffic_limit_bytes = NULL, subscription_token = COALESCE(subscription_token, @subscriptionToken), subscription_label = CASE WHEN subscription_label = '' THEN @username ELSE subscription_label END, updated_at = @now WHERE id = @id `, ).run({ id: existingAdminUser.id, username: env.ADMIN_USERNAME, passwordHash, secret: encryptText(env.ADMIN_PASSWORD), subscriptionToken: existingAdminUser.subscription_token ?? createOpaqueToken(), now, }); } else { db.prepare( ` INSERT INTO hy2_users ( auth_id, username, password_hash, enabled, expires_at, traffic_limit_bytes, used_tx_bytes, used_rx_bytes, notes, is_admin, auth_secret_encrypted, subscription_token, subscription_label, created_at, updated_at ) VALUES ( @authId, @username, @passwordHash, 1, NULL, NULL, 0, 0, 'System administrator account', 1, @secret, @subscriptionToken, @username, @now, @now ) `, ).run({ authId: `admin-${createStableId()}`, username: env.ADMIN_USERNAME, passwordHash, secret: encryptText(env.ADMIN_PASSWORD), subscriptionToken: createOpaqueToken(), now, }); } const usersMissingTokens = db .prepare("SELECT id, username FROM hy2_users WHERE subscription_token IS NULL") .all() as Array<{ id: number; username: string }>; const updateUserToken = db.prepare( ` UPDATE hy2_users SET subscription_token = ?, subscription_label = CASE WHEN subscription_label = '' THEN ? ELSE subscription_label END, updated_at = ? WHERE id = ? `, ); for (const user of usersMissingTokens) { updateUserToken.run(createOpaqueToken(), user.username, now, user.id); } db.prepare( ` UPDATE hy2_users SET subscription_label = username, updated_at = ? WHERE subscription_label = '' `, ).run(now); } export function getDb() { if (!global.__hy2PanelDb) { global.__hy2PanelDb = createDbInstance(); } return global.__hy2PanelDb; } export async function ensureDb() { if (!global.__hy2PanelDbInit) { global.__hy2PanelDbInit = initializeDb(getDb()); } await global.__hy2PanelDbInit; return getDb(); }