342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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<void> | 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("main_proxy_group_member_slugs_json", "[]", 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();
|
|
}
|