1063 lines
26 KiB
TypeScript
1063 lines
26 KiB
TypeScript
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<Hy2UserRow & { online_connections: number }>;
|
|
}
|
|
|
|
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<string, { tx: number; rx: number }>;
|
|
online: Record<string, number>;
|
|
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<T>(callback: (db: Database.Database) => T) {
|
|
const db = await ensureDb();
|
|
const transaction = db.transaction(callback);
|
|
return transaction(getDb());
|
|
}
|