import type Database from "better-sqlite3"; import { encryptText } from "@/lib/crypto"; import { ensureDb, getDb } from "@/lib/db"; import { getEnv } from "@/lib/env"; import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password"; export type Hy2UserRow = { id: number; auth_id: string; username: string; password_hash: string; auth_secret_encrypted: string | null; enabled: number; is_admin: number; expires_at: string | null; traffic_limit_bytes: number | null; used_tx_bytes: number; used_rx_bytes: number; notes: string; subscription_token: string | null; subscription_label: string; created_at: string; updated_at: string; }; export type Hy2NodeRow = { id: number; slug: string; name: string; auth_token: string; hy2_listen: string; traffic_stats_url: string; traffic_stats_secret: string; traffic_stats_listen: string; server_host: string; server_port: number; client_name: string; bandwidth_up_mbps: number; bandwidth_down_mbps: number; skip_cert_verify: number; sni: string | null; enabled: number; poll_interval_seconds: number; last_polled_at: string | null; last_sync_ok_at: string | null; last_error_at: string | null; last_error_message: string | null; last_online_users: number; last_stream_count: number; created_at: string; updated_at: string; }; export type ProxyGroupConfig = { id: string; name: string; type: "select"; memberSlugs: string[]; }; type AuditRecordInput = { nodeId?: number | null; userId?: number | null; authId?: string | null; username?: string | null; addr?: string | null; requestedTx?: number | null; ok: boolean; reason: string; }; function dbNow() { return new Date().toISOString(); } function asBoolean(value: number) { return value === 1; } export function formatBytes(bytes: number | null | undefined) { if (bytes == null) return "0 B"; if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB", "PB"]; let value = bytes / 1024; let unit = units[0]; for (let index = 1; index < units.length && value >= 1024; index += 1) { value /= 1024; unit = units[index]; } return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`; } export function toBytesFromGiB(input: string | null | undefined) { if (!input) return null; const value = Number(input); if (!Number.isFinite(value) || value <= 0) return null; return Math.round(value * 1024 * 1024 * 1024); } export function toDatetimeLocalValue(value: string | null) { if (!value) return ""; return value.slice(0, 16); } export function buildNodeAuthUrl(slug: string, token: string, origin = getEnv().APP_URL) { return `${origin}/api/hy2/nodes/${encodeURIComponent(slug)}/auth?token=${encodeURIComponent(token)}`; } export function buildTrafficStatsUrl(serverHost: string, listen: string) { const portMatch = listen.match(/:?(\d{2,5})$/); const port = portMatch?.[1] ?? "9999"; return `http://${serverHost}:${port}`; } export function getUserLifecycleState(user: { enabled: number; expires_at: string | null; traffic_limit_bytes: number | null; used_tx_bytes: number; used_rx_bytes: number; }) { if (!asBoolean(user.enabled)) { return { ok: false, reason: "user_disabled" }; } if (user.expires_at && new Date(user.expires_at).getTime() <= Date.now()) { return { ok: false, reason: "user_expired" }; } const total = user.used_tx_bytes + user.used_rx_bytes; if ( user.traffic_limit_bytes != null && Number.isFinite(user.traffic_limit_bytes) && total >= user.traffic_limit_bytes ) { return { ok: false, reason: "user_quota_exceeded" }; } return { ok: true, reason: "ok" }; } export async function getAdminByUsername(username: string) { const db = await ensureDb(); return db .prepare("SELECT * FROM admins WHERE username = ?") .get(username) as | { id: number; username: string; password_hash: string; } | undefined; } export async function listUsers() { const db = await ensureDb(); return db .prepare( ` SELECT u.*, COALESCE(SUM(p.connections), 0) AS online_connections FROM hy2_users u LEFT JOIN node_user_presence p ON p.user_id = u.id GROUP BY u.id ORDER BY u.created_at DESC `, ) .all() as Array; } export async function getUserByUsername(username: string) { const db = await ensureDb(); return db .prepare("SELECT * FROM hy2_users WHERE username = ?") .get(username) as Hy2UserRow | undefined; } export async function getUserById(id: number) { const db = await ensureDb(); return db .prepare("SELECT * FROM hy2_users WHERE id = ?") .get(id) as Hy2UserRow | undefined; } export async function getUserByAuthId(authId: string) { const db = await ensureDb(); return db .prepare("SELECT * FROM hy2_users WHERE auth_id = ?") .get(authId) as Hy2UserRow | undefined; } export async function getUserBySubscriptionToken(token: string) { const db = await ensureDb(); return db .prepare("SELECT * FROM hy2_users WHERE subscription_token = ?") .get(token) as Hy2UserRow | undefined; } export async function getAdminMirrorUser() { const db = await ensureDb(); return db .prepare( ` SELECT * FROM hy2_users WHERE is_admin = 1 OR username = ? ORDER BY is_admin DESC, id ASC LIMIT 1 `, ) .get(getEnv().ADMIN_USERNAME) as Hy2UserRow | undefined; } export async function createUser(input: { username: string; password: string; enabled: boolean; isAdmin?: boolean; expiresAt: string | null; trafficLimitBytes: number | null; notes: string; }) { const db = await ensureDb(); const now = dbNow(); const passwordHash = await hashPassword(input.password); const authId = createStableId(); db.prepare( ` INSERT INTO hy2_users ( auth_id, username, password_hash, auth_secret_encrypted, enabled, is_admin, expires_at, traffic_limit_bytes, notes, subscription_token, subscription_label, created_at, updated_at ) VALUES ( @authId, @username, @passwordHash, @authSecretEncrypted, @enabled, @isAdmin, @expiresAt, @trafficLimitBytes, @notes, @subscriptionToken, @subscriptionLabel, @now, @now ) `, ).run({ authId, username: input.username, passwordHash, authSecretEncrypted: encryptText(input.password), enabled: input.enabled ? 1 : 0, isAdmin: input.isAdmin ? 1 : 0, expiresAt: input.expiresAt, trafficLimitBytes: input.trafficLimitBytes, notes: input.notes, subscriptionToken: createOpaqueToken(), subscriptionLabel: input.username, now, }); } export async function updateUser(input: { id: number; username: string; enabled: boolean; subscriptionLabel: string; expiresAt: string | null; trafficLimitBytes: number | null; notes: string; }) { const db = await ensureDb(); db.prepare( ` UPDATE hy2_users SET username = @username, enabled = @enabled, subscription_label = @subscriptionLabel, expires_at = @expiresAt, traffic_limit_bytes = @trafficLimitBytes, notes = @notes, updated_at = @updatedAt WHERE id = @id `, ).run({ id: input.id, username: input.username, enabled: input.enabled ? 1 : 0, subscriptionLabel: input.subscriptionLabel, expiresAt: input.expiresAt, trafficLimitBytes: input.trafficLimitBytes, notes: input.notes, updatedAt: dbNow(), }); } export async function deleteUser(id: number) { const db = await ensureDb(); db.prepare("DELETE FROM hy2_users WHERE id = ?").run(id); } export async function resetUserPassword(id: number, password: string) { const db = await ensureDb(); const passwordHash = await hashPassword(password); db.prepare( ` UPDATE hy2_users SET password_hash = ?, auth_secret_encrypted = ?, updated_at = ? WHERE id = ? `, ).run(passwordHash, encryptText(password), dbNow(), id); } export async function listNodes() { const db = await ensureDb(); return db .prepare( ` SELECT * FROM hy2_nodes ORDER BY created_at DESC `, ) .all() as Hy2NodeRow[]; } export async function getNodeBySlug(slug: string) { const db = await ensureDb(); return db .prepare("SELECT * FROM hy2_nodes WHERE slug = ?") .get(slug) as Hy2NodeRow | undefined; } export async function createNode(input: { slug: string; name: string; authToken: string | null; hy2Listen: string; serverHost: string; serverPort: number; clientName: string; bandwidthUpMbps: number; bandwidthDownMbps: number; skipCertVerify: boolean; sni: string | null; trafficStatsUrl: string; trafficStatsListen: string; trafficStatsSecret: string; enabled: boolean; pollIntervalSeconds: number; }) { const db = await ensureDb(); const now = dbNow(); db.prepare( ` INSERT INTO hy2_nodes ( slug, name, auth_token, hy2_listen, server_host, server_port, client_name, bandwidth_up_mbps, bandwidth_down_mbps, skip_cert_verify, sni, traffic_stats_url, traffic_stats_listen, traffic_stats_secret, enabled, poll_interval_seconds, created_at, updated_at ) VALUES ( @slug, @name, @authToken, @hy2Listen, @serverHost, @serverPort, @clientName, @bandwidthUpMbps, @bandwidthDownMbps, @skipCertVerify, @sni, @trafficStatsUrl, @trafficStatsListen, @trafficStatsSecret, @enabled, @pollIntervalSeconds, @now, @now ) `, ).run({ slug: input.slug, name: input.name, authToken: input.authToken || createOpaqueToken(), hy2Listen: input.hy2Listen || `:${input.serverPort}`, serverHost: input.serverHost, serverPort: input.serverPort, clientName: input.clientName, bandwidthUpMbps: input.bandwidthUpMbps, bandwidthDownMbps: input.bandwidthDownMbps, skipCertVerify: input.skipCertVerify ? 1 : 0, sni: input.sni, trafficStatsUrl: input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen), trafficStatsListen: input.trafficStatsListen, trafficStatsSecret: input.trafficStatsSecret || createOpaqueToken(), enabled: input.enabled ? 1 : 0, pollIntervalSeconds: input.pollIntervalSeconds, now, }); } export async function updateNode(input: { id: number; slug: string; name: string; authToken: string; hy2Listen: string; serverHost: string; serverPort: number; clientName: string; bandwidthUpMbps: number; bandwidthDownMbps: number; skipCertVerify: boolean; sni: string | null; trafficStatsUrl: string; trafficStatsListen: string; trafficStatsSecret: string; enabled: boolean; pollIntervalSeconds: number; }) { const db = await ensureDb(); db.prepare( ` UPDATE hy2_nodes SET slug = @slug, name = @name, auth_token = @authToken, hy2_listen = @hy2Listen, server_host = @serverHost, server_port = @serverPort, client_name = @clientName, bandwidth_up_mbps = @bandwidthUpMbps, bandwidth_down_mbps = @bandwidthDownMbps, skip_cert_verify = @skipCertVerify, sni = @sni, traffic_stats_url = @trafficStatsUrl, traffic_stats_listen = @trafficStatsListen, traffic_stats_secret = CASE WHEN @trafficStatsSecret = '' THEN traffic_stats_secret ELSE @trafficStatsSecret END, enabled = @enabled, poll_interval_seconds = @pollIntervalSeconds, updated_at = @updatedAt WHERE id = @id `, ).run({ ...input, hy2Listen: input.hy2Listen || `:${input.serverPort}`, trafficStatsUrl: input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen), enabled: input.enabled ? 1 : 0, skipCertVerify: input.skipCertVerify ? 1 : 0, updatedAt: dbNow(), }); } export async function deleteNode(id: number) { const db = await ensureDb(); db.prepare("DELETE FROM hy2_nodes WHERE id = ?").run(id); } export async function listAudits(limit = 100) { const db = await ensureDb(); return db .prepare( ` SELECT a.*, n.name AS node_name FROM auth_audits a LEFT JOIN hy2_nodes n ON n.id = a.node_id ORDER BY a.created_at DESC LIMIT ? `, ) .all(limit) as Array<{ id: number; node_name: string | null; auth_id: string | null; username: string | null; addr: string | null; requested_tx: number | null; ok: number; reason: string; created_at: string; }>; } export async function getDashboardMetrics() { const db = await ensureDb(); const totals = db .prepare( ` SELECT (SELECT COUNT(*) FROM hy2_users) AS total_users, (SELECT COUNT(*) FROM hy2_users WHERE enabled = 1) AS enabled_users, (SELECT COUNT(*) FROM hy2_users WHERE is_admin = 1) AS admin_users, (SELECT COUNT(*) FROM hy2_nodes WHERE enabled = 1) AS enabled_nodes, (SELECT COUNT(*) FROM node_user_presence WHERE connections > 0) AS active_presence_rows, (SELECT COALESCE(SUM(connections), 0) FROM node_user_presence WHERE connections > 0) AS total_online_connections, (SELECT COALESCE(SUM(used_tx_bytes + used_rx_bytes), 0) FROM hy2_users) AS total_traffic `, ) .get() as { total_users: number; enabled_users: number; admin_users: number; enabled_nodes: number; active_presence_rows: number; total_online_connections: number; total_traffic: number; }; const topUsers = db .prepare( ` SELECT username, auth_id, used_tx_bytes, used_rx_bytes, (used_tx_bytes + used_rx_bytes) AS total_bytes FROM hy2_users ORDER BY total_bytes DESC LIMIT 8 `, ) .all() as Array<{ username: string; auth_id: string; used_tx_bytes: number; used_rx_bytes: number; total_bytes: number; }>; const nodeHealth = db .prepare( ` SELECT id, name, slug, enabled, last_sync_ok_at, last_error_at, last_error_message, last_online_users, last_stream_count FROM hy2_nodes ORDER BY name ASC `, ) .all() as Array<{ id: number; name: string; slug: string; enabled: number; last_sync_ok_at: string | null; last_error_at: string | null; last_error_message: string | null; last_online_users: number; last_stream_count: number; }>; const recentFailures = db .prepare( ` SELECT a.created_at, a.reason, a.username, a.addr, n.name AS node_name FROM auth_audits a LEFT JOIN hy2_nodes n ON n.id = a.node_id WHERE a.ok = 0 ORDER BY a.created_at DESC LIMIT 12 `, ) .all() as Array<{ created_at: string; reason: string; username: string | null; addr: string | null; node_name: string | null; }>; const recentUsage = db .prepare( ` SELECT l.created_at, l.auth_id, l.tx_bytes, l.rx_bytes, u.username, n.name AS node_name FROM traffic_ledgers l LEFT JOIN hy2_users u ON u.id = l.user_id LEFT JOIN hy2_nodes n ON n.id = l.node_id ORDER BY l.created_at DESC LIMIT 20 `, ) .all() as Array<{ created_at: string; auth_id: string; tx_bytes: number; rx_bytes: number; username: string | null; node_name: string | null; }>; const syncErrors = db .prepare( ` SELECT r.created_at, r.error_message, n.name AS node_name FROM node_sync_records r INNER JOIN hy2_nodes n ON n.id = r.node_id WHERE r.ok = 0 ORDER BY r.created_at DESC LIMIT 12 `, ) .all() as Array<{ created_at: string; error_message: string | null; node_name: string; }>; return { totals, topUsers, nodeHealth, recentFailures, recentUsage, syncErrors, }; } export async function createAdminSessionRecord(input: { id: string; adminId: number; sessionHash: string; expiresAt: string; }) { const db = await ensureDb(); const now = dbNow(); db.prepare( ` INSERT INTO admin_sessions (id, admin_id, session_hash, expires_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?) `, ).run(input.id, input.adminId, input.sessionHash, input.expiresAt, now, now); } export async function getAdminSessionByHash(sessionHash: string) { const db = await ensureDb(); return db .prepare( ` SELECT s.id, s.admin_id, s.expires_at, s.last_seen_at, a.username FROM admin_sessions s INNER JOIN admins a ON a.id = s.admin_id WHERE s.session_hash = ? `, ) .get(sessionHash) as | { id: string; admin_id: number; username: string; expires_at: string; last_seen_at: string; } | undefined; } export async function touchAdminSession(sessionId: string) { const db = await ensureDb(); db.prepare("UPDATE admin_sessions SET last_seen_at = ? WHERE id = ?").run( dbNow(), sessionId, ); } export async function createUserSessionRecord(input: { id: string; userId: number; sessionHash: string; expiresAt: string; }) { const db = await ensureDb(); const now = dbNow(); db.prepare( ` INSERT INTO user_sessions (id, user_id, session_hash, expires_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?) `, ).run(input.id, input.userId, input.sessionHash, input.expiresAt, now, now); } export async function getUserSessionByHash(sessionHash: string) { const db = await ensureDb(); return db .prepare( ` SELECT s.id, s.user_id, s.expires_at, s.last_seen_at, u.username, u.is_admin FROM user_sessions s INNER JOIN hy2_users u ON u.id = s.user_id WHERE s.session_hash = ? `, ) .get(sessionHash) as | { id: string; user_id: number; username: string; is_admin: number; expires_at: string; last_seen_at: string; } | undefined; } export async function touchUserSession(sessionId: string) { const db = await ensureDb(); db.prepare("UPDATE user_sessions SET last_seen_at = ? WHERE id = ?").run( dbNow(), sessionId, ); } export async function deleteUserSessionByHash(sessionHash: string) { const db = await ensureDb(); db.prepare("DELETE FROM user_sessions WHERE session_hash = ?").run(sessionHash); } export async function deleteAdminSessionByHash(sessionHash: string) { const db = await ensureDb(); db.prepare("DELETE FROM admin_sessions WHERE session_hash = ?").run(sessionHash); } export async function getSubscriptionSettings() { const db = await ensureDb(); const rows = db .prepare("SELECT key, value FROM app_settings") .all() as Array<{ key: string; value: string }>; const map = new Map(rows.map((row) => [row.key, row.value])); return { template: map.get("clash_template") ?? "", extraProxyGroups: map.get("extra_proxy_groups") ?? "", profileName: map.get("profile_name") ?? "FeieProxy", proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[], }; } export async function saveSubscriptionSettings(input: { template: string; extraProxyGroups: string; profileName: string; }) { const db = await ensureDb(); const upsert = db.prepare( ` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `, ); const now = dbNow(); upsert.run("clash_template", input.template, now); upsert.run("extra_proxy_groups", input.extraProxyGroups, now); upsert.run("profile_name", input.profileName, now); } export async function saveProxyGroups(proxyGroups: ProxyGroupConfig[]) { const db = await ensureDb(); const now = dbNow(); db.prepare( ` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `, ).run("proxy_groups_json", JSON.stringify(proxyGroups), now); } export async function listSubscriptionNodes() { const db = await ensureDb(); return db .prepare( ` SELECT * FROM hy2_nodes WHERE enabled = 1 ORDER BY created_at DESC `, ) .all() as Hy2NodeRow[]; } export async function writeAuthAudit(input: AuditRecordInput) { const db = await ensureDb(); db.prepare( ` INSERT INTO auth_audits ( node_id, user_id, auth_id, username, addr, requested_tx, ok, reason, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( input.nodeId ?? null, input.userId ?? null, input.authId ?? null, input.username ?? null, input.addr ?? null, input.requestedTx ?? null, input.ok ? 1 : 0, input.reason, dbNow(), ); } export async function listEnabledNodesForPolling() { const db = await ensureDb(); return db .prepare( ` SELECT * FROM hy2_nodes WHERE enabled = 1 ORDER BY id ASC `, ) .all() as Hy2NodeRow[]; } export async function shouldPollNode(nodeId: number, intervalSeconds: number) { const db = await ensureDb(); const row = db .prepare("SELECT last_polled_at FROM hy2_nodes WHERE id = ?") .get(nodeId) as { last_polled_at: string | null } | undefined; if (!row?.last_polled_at) { return true; } return Date.now() - new Date(row.last_polled_at).getTime() >= intervalSeconds * 1000; } export function applyNodeSync( db: Database.Database, input: { node: Hy2NodeRow; startedAt: string; finishedAt: string; traffic: Record; online: Record; streamCount: number; kickedAuthIds: string[]; }, ) { const authIds = new Set([ ...Object.keys(input.traffic), ...Object.keys(input.online), ...input.kickedAuthIds, ]); const lookupUsers = db.prepare( ` SELECT id, auth_id FROM hy2_users WHERE auth_id IN (${Array.from(authIds) .map(() => "?") .join(",") || "''"}) `, ); const users = authIds.size ? (lookupUsers.all(...Array.from(authIds)) as Array<{ id: number; auth_id: string }>) : []; const userMap = new Map(users.map((user) => [user.auth_id, user.id])); const now = input.finishedAt; const insertLedger = db.prepare( ` INSERT INTO traffic_ledgers ( node_id, user_id, auth_id, tx_bytes, rx_bytes, window_started_at, window_ended_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, ); const updateUserTraffic = db.prepare( ` UPDATE hy2_users SET used_tx_bytes = used_tx_bytes + ?, used_rx_bytes = used_rx_bytes + ?, updated_at = ? WHERE auth_id = ? `, ); for (const [authId, counters] of Object.entries(input.traffic)) { insertLedger.run( input.node.id, userMap.get(authId) ?? null, authId, counters.tx, counters.rx, input.startedAt, input.finishedAt, now, ); updateUserTraffic.run(counters.tx, counters.rx, now, authId); } db.prepare( ` UPDATE node_user_presence SET connections = 0, updated_at = ? WHERE node_id = ? `, ).run(now, input.node.id); const upsertPresence = db.prepare( ` INSERT INTO node_user_presence (node_id, user_id, auth_id, connections, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(node_id, auth_id) DO UPDATE SET user_id = excluded.user_id, connections = excluded.connections, updated_at = excluded.updated_at `, ); for (const [authId, connections] of Object.entries(input.online)) { upsertPresence.run( input.node.id, userMap.get(authId) ?? null, authId, connections, now, ); } db.prepare( ` UPDATE hy2_nodes SET last_polled_at = ?, last_sync_ok_at = ?, last_error_at = NULL, last_error_message = NULL, last_online_users = ?, last_stream_count = ?, updated_at = ? WHERE id = ? `, ).run( now, now, Object.keys(input.online).length, input.streamCount, now, input.node.id, ); db.prepare( ` INSERT INTO node_sync_records ( node_id, started_at, finished_at, ok, traffic_entries, online_users, stream_count, kicked_auth_ids, error_message, created_at ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, NULL, ?) `, ).run( input.node.id, input.startedAt, input.finishedAt, Object.keys(input.traffic).length, Object.keys(input.online).length, input.streamCount, input.kickedAuthIds.join(","), now, ); } export async function writeNodeSyncError(nodeId: number, startedAt: string, errorMessage: string) { const db = await ensureDb(); const now = dbNow(); db.prepare( ` UPDATE hy2_nodes SET last_polled_at = ?, last_error_at = ?, last_error_message = ?, updated_at = ? WHERE id = ? `, ).run(now, now, errorMessage, now, nodeId); db.prepare( ` INSERT INTO node_sync_records ( node_id, started_at, finished_at, ok, traffic_entries, online_users, stream_count, kicked_auth_ids, error_message, created_at ) VALUES (?, ?, ?, 0, 0, 0, 0, '', ?, ?) `, ).run(nodeId, startedAt, now, errorMessage, now); } export async function getKickCandidates(authIds: string[]) { if (authIds.length === 0) { return []; } const db = await ensureDb(); const placeholders = authIds.map(() => "?").join(","); const rows = db .prepare( ` SELECT * FROM hy2_users WHERE auth_id IN (${placeholders}) `, ) .all(...authIds) as Hy2UserRow[]; const map = new Map(rows.map((row) => [row.auth_id, row])); return authIds.filter((authId) => { const user = map.get(authId); if (!user) return true; return !getUserLifecycleState(user).ok; }); } export async function runInTransaction(callback: (db: Database.Database) => T) { const db = await ensureDb(); const transaction = db.transaction(callback); return transaction(getDb()); }