feat: 支持自定义订阅节点并兼容现有数据库
This commit is contained in:
@@ -159,3 +159,21 @@ npm run test:e2e
|
|||||||
```
|
```
|
||||||
|
|
||||||
测试自动启动 3106 端口的开发服务,使用 `/tmp` 中独立的临时数据库并关闭节点轮询,不访问现有数据库。覆盖排序持久化、鼠标/键盘/模拟触屏、列表筛选、用户编辑、复制链接、删除确认、失败保留输入和移动端溢出检查。截图输出在 `test-results/`。运行前请停止同一工作目录的其他 Next 开发服务。
|
测试自动启动 3106 端口的开发服务,使用 `/tmp` 中独立的临时数据库并关闭节点轮询,不访问现有数据库。覆盖排序持久化、鼠标/键盘/模拟触屏、列表筛选、用户编辑、复制链接、删除确认、失败保留输入和移动端溢出检查。截图输出在 `test-results/`。运行前请停止同一工作目录的其他 Next 开发服务。
|
||||||
|
|
||||||
|
## 自定义订阅节点与数据库兼容
|
||||||
|
|
||||||
|
在「节点管理 → 新增自定义订阅节点」填写显示名、Slug 和单个 Clash/Mihomo proxy 的 YAML。支持单对象、单节点列表,以及 `proxies:` 包装。协议参数(例如 AnyTLS 的 password、sni、alpn)由 YAML 提供;订阅名使用 YAML `name`,面板显示名独立。原始 YAML 和注释按原样保存,输出订阅时才解析、序列化。格式错误会在表单内提示并保留输入。
|
||||||
|
|
||||||
|
自定义节点与 HY2 节点共用列表、启停、删除、主组拖拽排序及自定义分组。自定义节点仅分发配置,不参与 HY2 HTTP 鉴权、轮询或面板流量计量,也不生成 HY2 服务端配置。代理的访问控制和流量限制由该代理服务自身负责。
|
||||||
|
|
||||||
|
数据库升级沿用服务器现有的 **SQLite `user_version = 4`**,保留 `hy2_nodes.node_type`(默认 `hy2`)和 `hy2_nodes.custom_config_yaml`(默认空字符串)的原名称、类型和默认值:
|
||||||
|
|
||||||
|
- 全新数据库、版本 1–3 的数据库在启动时自动迁移。
|
||||||
|
- 服务器已有的完整版本 4 不再重复添加列,也不重写现有记录。
|
||||||
|
- 对于版本 3/4 中已部分添加的自定义节点字段,只补充缺失列。
|
||||||
|
- 迁移在一个 SQLite 写事务中执行,失败回滚;更高版本的数据库会拒绝启动,避免错误降级。
|
||||||
|
- 节点 ID、用户 ID、订阅 token、加密凭据、额度、会话、历史流量和代理分组 JSON 都不因迁移而重建。
|
||||||
|
|
||||||
|
升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。
|
||||||
|
|
||||||
|
`tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { DashboardCharts } from "./dashboard-charts";
|
|||||||
|
|
||||||
function getNodePill(node: {
|
function getNodePill(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
|
node_type: "hy2" | "custom";
|
||||||
last_error_message: string | null;
|
last_error_message: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!node.enabled) {
|
if (!node.enabled) {
|
||||||
return { label: "已禁用", className: "status-pill status-pill--off" };
|
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.node_type === "custom")
|
||||||
|
return { label: "自定义订阅", className: "status-pill status-pill--ink" };
|
||||||
|
|
||||||
if (node.last_error_message) {
|
if (node.last_error_message) {
|
||||||
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||||||
}
|
}
|
||||||
@@ -66,7 +70,7 @@ export default async function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DashboardCharts
|
<DashboardCharts
|
||||||
nodeHealth={data.nodeHealth}
|
nodeHealth={data.nodeHealth.filter((node) => node.node_type === "hy2")}
|
||||||
trafficTrend={data.trafficTrend}
|
trafficTrend={data.trafficTrend}
|
||||||
trafficBuckets={data.trafficBuckets}
|
trafficBuckets={data.trafficBuckets}
|
||||||
trafficUserOptions={data.trafficUserOptions}
|
trafficUserOptions={data.trafficUserOptions}
|
||||||
@@ -103,11 +107,15 @@ export default async function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
{node.node_type === "custom"
|
||||||
</p>
|
? "仅订阅 · 不参与轮询"
|
||||||
<p className="muted">
|
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
|
||||||
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
|
||||||
</p>
|
</p>
|
||||||
|
{node.node_type === "hy2" && (
|
||||||
|
<p className="muted">
|
||||||
|
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{node.last_error_message ? (
|
{node.last_error_message ? (
|
||||||
<p className="error-text">{node.last_error_message}</p>
|
<p className="error-text">{node.last_error_message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+54
-12
@@ -1,69 +1,110 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomProxyValidationError,
|
||||||
|
parseCustomProxyYaml,
|
||||||
|
} from "@/lib/custom-proxy";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
import { requireAdminAccess } from "@/lib/session";
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
import { createNode, deleteNode, updateNode } from "@/lib/store";
|
import {
|
||||||
|
createNode,
|
||||||
|
deleteNode,
|
||||||
|
normalizeNodeType,
|
||||||
|
updateNode,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
function boolFromForm(value: FormDataEntryValue | null) {
|
function boolFromForm(value: FormDataEntryValue | null) {
|
||||||
return value === "on";
|
return value === "on";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateCustomYaml(nodeType: string, formData: FormData) {
|
||||||
|
if (nodeType !== "custom") return;
|
||||||
|
try {
|
||||||
|
parseCustomProxyYaml(String(formData.get("custom_config_yaml") ?? ""));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CustomProxyValidationError)
|
||||||
|
return { error: error.message };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createNodeAction(formData: FormData) {
|
export async function createNodeAction(formData: FormData) {
|
||||||
await requireAdminAccess();
|
await requireAdminAccess();
|
||||||
|
const nodeType = normalizeNodeType(
|
||||||
|
String(formData.get("node_type") ?? "hy2"),
|
||||||
|
);
|
||||||
|
const validation = validateCustomYaml(nodeType, formData);
|
||||||
|
if (validation) return validation;
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const clientName = String(formData.get("client_name") ?? "").trim();
|
||||||
|
|
||||||
await createNode({
|
await createNode({
|
||||||
|
nodeType,
|
||||||
slug: String(formData.get("slug") ?? "").trim(),
|
slug: String(formData.get("slug") ?? "").trim(),
|
||||||
name: String(formData.get("name") ?? "").trim(),
|
name,
|
||||||
authToken: String(formData.get("auth_token") ?? "").trim() || null,
|
authToken: String(formData.get("auth_token") ?? "").trim() || null,
|
||||||
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||||
serverHost: String(formData.get("server_host") ?? "").trim(),
|
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||||
serverPort: Number(formData.get("server_port") ?? 443),
|
serverPort: Number(formData.get("server_port") ?? 443),
|
||||||
clientName:
|
clientName: clientName || (nodeType === "hy2" ? name : ""),
|
||||||
String(formData.get("client_name") ?? "").trim() ||
|
|
||||||
String(formData.get("name") ?? "").trim(),
|
|
||||||
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||||
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||||
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||||
sni: String(formData.get("sni") ?? "").trim() || null,
|
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||||
|
customConfigYaml: String(formData.get("custom_config_yaml") ?? ""),
|
||||||
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||||
trafficStatsListen:
|
trafficStatsListen:
|
||||||
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||||
trafficStatsSecret:
|
trafficStatsSecret: String(
|
||||||
String(formData.get("traffic_stats_secret") ?? "").trim(),
|
formData.get("traffic_stats_secret") ?? "",
|
||||||
|
).trim(),
|
||||||
enabled: boolFromForm(formData.get("enabled")),
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/nodes");
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
revalidatePath("/admin/dashboard");
|
revalidatePath("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNodeAction(formData: FormData) {
|
export async function updateNodeAction(formData: FormData) {
|
||||||
await requireAdminAccess();
|
await requireAdminAccess();
|
||||||
|
const nodeType = normalizeNodeType(
|
||||||
|
String(formData.get("node_type") ?? "hy2"),
|
||||||
|
);
|
||||||
|
const validation = validateCustomYaml(nodeType, formData);
|
||||||
|
if (validation) return validation;
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const clientName = String(formData.get("client_name") ?? "").trim();
|
||||||
|
|
||||||
await updateNode({
|
await updateNode({
|
||||||
id: Number(formData.get("id")),
|
id: Number(formData.get("id")),
|
||||||
|
nodeType,
|
||||||
slug: String(formData.get("slug") ?? "").trim(),
|
slug: String(formData.get("slug") ?? "").trim(),
|
||||||
name: String(formData.get("name") ?? "").trim(),
|
name,
|
||||||
authToken: String(formData.get("auth_token") ?? "").trim(),
|
authToken: String(formData.get("auth_token") ?? "").trim(),
|
||||||
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||||
serverHost: String(formData.get("server_host") ?? "").trim(),
|
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||||
serverPort: Number(formData.get("server_port") ?? 443),
|
serverPort: Number(formData.get("server_port") ?? 443),
|
||||||
clientName:
|
clientName: clientName || (nodeType === "hy2" ? name : ""),
|
||||||
String(formData.get("client_name") ?? "").trim() ||
|
|
||||||
String(formData.get("name") ?? "").trim(),
|
|
||||||
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||||
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||||
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||||
sni: String(formData.get("sni") ?? "").trim() || null,
|
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||||
|
customConfigYaml: String(formData.get("custom_config_yaml") ?? ""),
|
||||||
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||||
trafficStatsListen:
|
trafficStatsListen:
|
||||||
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||||
trafficStatsSecret: String(formData.get("traffic_stats_secret") ?? "").trim(),
|
trafficStatsSecret: String(
|
||||||
|
formData.get("traffic_stats_secret") ?? "",
|
||||||
|
).trim(),
|
||||||
enabled: boolFromForm(formData.get("enabled")),
|
enabled: boolFromForm(formData.get("enabled")),
|
||||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/nodes");
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
revalidatePath("/admin/dashboard");
|
revalidatePath("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,5 +112,6 @@ export async function deleteNodeAction(formData: FormData) {
|
|||||||
await requireAdminAccess();
|
await requireAdminAccess();
|
||||||
await deleteNode(Number(formData.get("id")));
|
await deleteNode(Number(formData.get("id")));
|
||||||
revalidatePath("/admin/nodes");
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
revalidatePath("/admin/dashboard");
|
revalidatePath("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import type { Hy2NodeRow } from "@/lib/store";
|
||||||
|
import { createNodeAction, updateNodeAction } from "./actions";
|
||||||
|
|
||||||
|
const example = `- name: "HK AnyTLS"
|
||||||
|
type: anytls
|
||||||
|
server: hk.example.com
|
||||||
|
port: 443
|
||||||
|
password: "your-password"
|
||||||
|
sni: hk.example.com
|
||||||
|
udp: true`;
|
||||||
|
|
||||||
|
export function CustomNodeForm({ node }: { node?: Hy2NodeRow }) {
|
||||||
|
return (
|
||||||
|
<ActionForm
|
||||||
|
action={node ? updateNodeAction : createNodeAction}
|
||||||
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
|
>
|
||||||
|
{node && <input name="id" type="hidden" value={node.id} />}
|
||||||
|
<input name="node_type" type="hidden" value="custom" />
|
||||||
|
<label className="field">
|
||||||
|
<span>Slug</span>
|
||||||
|
<Input
|
||||||
|
name="slug"
|
||||||
|
defaultValue={node?.slug}
|
||||||
|
placeholder="hk-anytls"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>显示名</span>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
defaultValue={node?.name}
|
||||||
|
placeholder="香港 · AnyTLS"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={node ? Boolean(node.enabled) : true}
|
||||||
|
/>
|
||||||
|
<span>启用节点</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted full-span">
|
||||||
|
仅写入订阅,不使用 HY2 鉴权和流量统计。订阅中的节点名称以 YAML 的 name
|
||||||
|
为准。
|
||||||
|
</p>
|
||||||
|
<label className="field full-span">
|
||||||
|
<span>节点 YAML</span>
|
||||||
|
<textarea
|
||||||
|
className="mono-textarea"
|
||||||
|
name="custom_config_yaml"
|
||||||
|
defaultValue={node?.custom_config_yaml}
|
||||||
|
placeholder={example}
|
||||||
|
rows={9}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
required
|
||||||
|
aria-describedby={node ? `yaml-help-${node.id}` : "yaml-help-new"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="full-span stack-tight">
|
||||||
|
<p
|
||||||
|
className="muted text-xs"
|
||||||
|
id={node ? `yaml-help-${node.id}` : "yaml-help-new"}
|
||||||
|
>
|
||||||
|
支持单个 proxy 对象、单节点列表,或 proxies
|
||||||
|
包装;每条记录一个节点。保存后可在「订阅配置」中拖拽排序、加入分组。
|
||||||
|
</p>
|
||||||
|
<details className="config-collapse">
|
||||||
|
<summary>查看 AnyTLS 示例</summary>
|
||||||
|
<pre>{example}</pre>
|
||||||
|
<div className="p-2">
|
||||||
|
<CopyButton value={example} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<SubmitButton variant={node ? "outline" : "default"}>
|
||||||
|
{node ? "保存自定义节点" : "创建自定义节点"}
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
+223
-173
@@ -1,3 +1,4 @@
|
|||||||
|
import { CustomNodeForm } from "./custom-node-form";
|
||||||
import { ActionForm } from "@/components/action-form";
|
import { ActionForm } from "@/components/action-form";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { CopyButton } from "@/components/copy-button";
|
import { CopyButton } from "@/components/copy-button";
|
||||||
@@ -7,7 +8,12 @@ import { SubmitButton } from "@/components/submit-button";
|
|||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
import {
|
||||||
|
buildNodeAuthUrl,
|
||||||
|
buildTrafficStatsUrl,
|
||||||
|
getNodeProxyName,
|
||||||
|
listNodes,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNodeAction,
|
createNodeAction,
|
||||||
@@ -17,6 +23,7 @@ import {
|
|||||||
|
|
||||||
function getNodePills(node: {
|
function getNodePills(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
|
node_type: "hy2" | "custom";
|
||||||
last_error_message: string | null;
|
last_error_message: string | null;
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
@@ -24,9 +31,11 @@ function getNodePills(node: {
|
|||||||
label: node.enabled ? "已启用" : "已禁用",
|
label: node.enabled ? "已启用" : "已禁用",
|
||||||
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
|
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
|
||||||
},
|
},
|
||||||
node.last_error_message
|
node.node_type === "custom"
|
||||||
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
|
||||||
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
: node.last_error_message
|
||||||
|
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
||||||
|
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +63,7 @@ export default async function NodesPage() {
|
|||||||
<details>
|
<details>
|
||||||
<summary className="compact-create-summary">
|
<summary className="compact-create-summary">
|
||||||
<div>
|
<div>
|
||||||
<h3>新增节点</h3>
|
<h3>新增 HY2 节点</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--warm">展开</span>
|
<span className="status-pill status-pill--warm">展开</span>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -62,6 +71,7 @@ export default async function NodesPage() {
|
|||||||
action={createNodeAction}
|
action={createNodeAction}
|
||||||
className="dense-form-grid dense-form-grid--nodes"
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
>
|
>
|
||||||
|
<input type="hidden" name="node_type" value="hy2" />
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Slug</span>
|
<span>Slug</span>
|
||||||
<Input name="slug" placeholder="tokyo-01" required />
|
<Input name="slug" placeholder="tokyo-01" required />
|
||||||
@@ -169,6 +179,15 @@ export default async function NodesPage() {
|
|||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="panel-card compact-create-panel">
|
||||||
|
<details>
|
||||||
|
<summary className="compact-create-summary">
|
||||||
|
<h3>新增自定义订阅节点</h3>
|
||||||
|
</summary>
|
||||||
|
<CustomNodeForm />
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="panel-card dense-panel">
|
<section className="panel-card dense-panel">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>节点列表</h3>
|
<h3>节点列表</h3>
|
||||||
@@ -195,6 +214,8 @@ export default async function NodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={nodes.map((node) => {
|
items={nodes.map((node) => {
|
||||||
|
const isCustom = node.node_type === "custom";
|
||||||
|
const proxyName = getNodeProxyName(node);
|
||||||
const authUrl = buildNodeAuthUrl(
|
const authUrl = buildNodeAuthUrl(
|
||||||
node.slug,
|
node.slug,
|
||||||
node.auth_token,
|
node.auth_token,
|
||||||
@@ -207,7 +228,7 @@ export default async function NodesPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
text: `${node.name} ${node.slug} ${node.server_host} ${node.client_name}`,
|
text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : "HY2"}`,
|
||||||
status: node.enabled ? "enabled" : "disabled",
|
status: node.enabled ? "enabled" : "disabled",
|
||||||
content: (
|
content: (
|
||||||
<details key={node.id} className="entity-table__item">
|
<details key={node.id} className="entity-table__item">
|
||||||
@@ -224,22 +245,32 @@ export default async function NodesPage() {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-code-cell">
|
<span className="entity-code-cell">
|
||||||
{node.server_host}:{node.server_port}
|
{isCustom
|
||||||
<small>hy2 {node.hy2_listen}</small>
|
? proxyName
|
||||||
|
: `${node.server_host}:${node.server_port}`}
|
||||||
|
<small>
|
||||||
|
{isCustom ? "自定义订阅节点" : `hy2 ${node.hy2_listen}`}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-code-cell break-all">
|
<span className="entity-code-cell break-all">
|
||||||
{node.traffic_stats_url}
|
{isCustom ? "不轮询" : node.traffic_stats_url}
|
||||||
<small>{node.traffic_stats_listen}</small>
|
<small>
|
||||||
|
{isCustom ? "仅订阅" : node.traffic_stats_listen}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>
|
<strong>
|
||||||
{node.last_online_users} / {node.last_stream_count}
|
{isCustom
|
||||||
|
? "—"
|
||||||
|
: `${node.last_online_users} / ${node.last_stream_count}`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>用户 / 流</span>
|
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-muted-cell">
|
<span className="entity-muted-cell">
|
||||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
{isCustom
|
||||||
{node.last_error_at ? (
|
? "—"
|
||||||
|
: formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
|
{!isCustom && node.last_error_at ? (
|
||||||
<small>
|
<small>
|
||||||
错误 {formatLocalDateTime(node.last_error_at)}
|
错误 {formatLocalDateTime(node.last_error_at)}
|
||||||
</small>
|
</small>
|
||||||
@@ -251,169 +282,188 @@ export default async function NodesPage() {
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="entity-table__details">
|
<div className="entity-table__details">
|
||||||
<div className="dense-code-grid">
|
{isCustom ? (
|
||||||
<div className="dense-code-line">
|
<CustomNodeForm node={node} />
|
||||||
<span>鉴权 URL</span>
|
) : (
|
||||||
<div className="copy-line">
|
<>
|
||||||
<code>{authUrl}</code>
|
<div className="dense-code-grid">
|
||||||
<CopyButton value={authUrl} />
|
<div className="dense-code-line">
|
||||||
|
<span>鉴权 URL</span>
|
||||||
|
<div className="copy-line">
|
||||||
|
<code>{authUrl}</code>
|
||||||
|
<CopyButton value={authUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dense-code-line">
|
||||||
|
<span>自动推导 trafficStats</span>
|
||||||
|
<code>{suggestedTrafficStatsUrl}</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="dense-code-line">
|
|
||||||
<span>自动推导 trafficStats</span>
|
|
||||||
<code>{suggestedTrafficStatsUrl}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ActionForm
|
<ActionForm
|
||||||
action={updateNodeAction}
|
action={updateNodeAction}
|
||||||
className="dense-form-grid dense-form-grid--nodes"
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
>
|
>
|
||||||
<input name="id" type="hidden" value={node.id} />
|
<input name="id" type="hidden" value={node.id} />
|
||||||
<label className="field">
|
<input name="node_type" type="hidden" value="hy2" />
|
||||||
<span>Slug</span>
|
<label className="field">
|
||||||
<Input name="slug" defaultValue={node.slug} required />
|
<span>Slug</span>
|
||||||
</label>
|
<Input
|
||||||
<label className="field">
|
name="slug"
|
||||||
<span>显示名</span>
|
defaultValue={node.slug}
|
||||||
<Input name="name" defaultValue={node.name} required />
|
required
|
||||||
</label>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>客户端地址</span>
|
<label className="field">
|
||||||
<Input
|
<span>显示名</span>
|
||||||
name="server_host"
|
<Input
|
||||||
defaultValue={node.server_host}
|
name="name"
|
||||||
required
|
defaultValue={node.name}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>hy2 监听地址</span>
|
<label className="field">
|
||||||
<Input
|
<span>客户端地址</span>
|
||||||
name="hy2_listen"
|
<Input
|
||||||
defaultValue={node.hy2_listen}
|
name="server_host"
|
||||||
placeholder=":443 或 0.0.0.0:443"
|
defaultValue={node.server_host}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>对外端口</span>
|
<label className="field">
|
||||||
<Input
|
<span>hy2 监听地址</span>
|
||||||
name="server_port"
|
<Input
|
||||||
type="number"
|
name="hy2_listen"
|
||||||
min="1"
|
defaultValue={node.hy2_listen}
|
||||||
max="65535"
|
placeholder=":443 或 0.0.0.0:443"
|
||||||
defaultValue={node.server_port}
|
/>
|
||||||
required
|
</label>
|
||||||
/>
|
<label className="field">
|
||||||
</label>
|
<span>对外端口</span>
|
||||||
<label className="field">
|
<Input
|
||||||
<span>Clash 节点名</span>
|
name="server_port"
|
||||||
<Input
|
type="number"
|
||||||
name="client_name"
|
min="1"
|
||||||
defaultValue={node.client_name}
|
max="65535"
|
||||||
/>
|
defaultValue={node.server_port}
|
||||||
</label>
|
required
|
||||||
<label className="field">
|
/>
|
||||||
<span>鉴权 Token</span>
|
</label>
|
||||||
<Input
|
<label className="field">
|
||||||
name="auth_token"
|
<span>Clash 节点名</span>
|
||||||
defaultValue={node.auth_token}
|
<Input
|
||||||
required
|
name="client_name"
|
||||||
/>
|
defaultValue={node.client_name}
|
||||||
</label>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>上行带宽 Mbps</span>
|
<label className="field">
|
||||||
<Input
|
<span>鉴权 Token</span>
|
||||||
name="bandwidth_up_mbps"
|
<Input
|
||||||
type="number"
|
name="auth_token"
|
||||||
min="1"
|
defaultValue={node.auth_token}
|
||||||
defaultValue={node.bandwidth_up_mbps}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<label className="field">
|
||||||
<label className="field">
|
<span>上行带宽 Mbps</span>
|
||||||
<span>下行带宽 Mbps</span>
|
<Input
|
||||||
<Input
|
name="bandwidth_up_mbps"
|
||||||
name="bandwidth_down_mbps"
|
type="number"
|
||||||
type="number"
|
min="1"
|
||||||
min="1"
|
defaultValue={node.bandwidth_up_mbps}
|
||||||
defaultValue={node.bandwidth_down_mbps}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<label className="field">
|
||||||
<label className="field">
|
<span>下行带宽 Mbps</span>
|
||||||
<span>SNI</span>
|
<Input
|
||||||
<Input name="sni" defaultValue={node.sni ?? ""} />
|
name="bandwidth_down_mbps"
|
||||||
</label>
|
type="number"
|
||||||
<label className="checkbox">
|
min="1"
|
||||||
<input
|
defaultValue={node.bandwidth_down_mbps}
|
||||||
name="skip_cert_verify"
|
required
|
||||||
type="checkbox"
|
/>
|
||||||
defaultChecked={Boolean(node.skip_cert_verify)}
|
</label>
|
||||||
/>
|
<label className="field">
|
||||||
<span>跳过证书校验</span>
|
<span>SNI</span>
|
||||||
</label>
|
<Input name="sni" defaultValue={node.sni ?? ""} />
|
||||||
<label className="field">
|
</label>
|
||||||
<span>trafficStats 监听</span>
|
<label className="checkbox">
|
||||||
<Input
|
<input
|
||||||
name="traffic_stats_listen"
|
name="skip_cert_verify"
|
||||||
defaultValue={node.traffic_stats_listen}
|
type="checkbox"
|
||||||
required
|
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||||
/>
|
/>
|
||||||
</label>
|
<span>跳过证书校验</span>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>trafficStats URL</span>
|
<label className="field">
|
||||||
<Input
|
<span>trafficStats 监听</span>
|
||||||
name="traffic_stats_url"
|
<Input
|
||||||
defaultValue={node.traffic_stats_url}
|
name="traffic_stats_listen"
|
||||||
/>
|
defaultValue={node.traffic_stats_listen}
|
||||||
</label>
|
required
|
||||||
<label className="field">
|
/>
|
||||||
<span>trafficStats Secret</span>
|
</label>
|
||||||
<Input
|
<label className="field">
|
||||||
name="traffic_stats_secret"
|
<span>trafficStats URL</span>
|
||||||
defaultValue={node.traffic_stats_secret}
|
<Input
|
||||||
placeholder="留空保持当前值"
|
name="traffic_stats_url"
|
||||||
/>
|
defaultValue={node.traffic_stats_url}
|
||||||
</label>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>轮询间隔(秒)</span>
|
<label className="field">
|
||||||
<Input
|
<span>trafficStats Secret</span>
|
||||||
name="poll_interval_seconds"
|
<Input
|
||||||
type="number"
|
name="traffic_stats_secret"
|
||||||
min="10"
|
defaultValue={node.traffic_stats_secret}
|
||||||
max="30"
|
placeholder="留空保持当前值"
|
||||||
defaultValue={node.poll_interval_seconds}
|
/>
|
||||||
required
|
</label>
|
||||||
/>
|
<label className="field">
|
||||||
</label>
|
<span>轮询间隔(秒)</span>
|
||||||
<label className="checkbox">
|
<Input
|
||||||
<input
|
name="poll_interval_seconds"
|
||||||
name="enabled"
|
type="number"
|
||||||
type="checkbox"
|
min="10"
|
||||||
defaultChecked={Boolean(node.enabled)}
|
max="30"
|
||||||
/>
|
defaultValue={node.poll_interval_seconds}
|
||||||
<span>启用节点</span>
|
required
|
||||||
</label>
|
/>
|
||||||
<SubmitButton variant="outline">保存节点</SubmitButton>
|
</label>
|
||||||
</ActionForm>
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.enabled)}
|
||||||
|
/>
|
||||||
|
<span>启用节点</span>
|
||||||
|
</label>
|
||||||
|
<SubmitButton variant="outline">
|
||||||
|
保存节点
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
<details className="config-collapse">
|
<details className="config-collapse">
|
||||||
<summary>查看 hy2 config.yaml</summary>
|
<summary>查看 hy2 config.yaml</summary>
|
||||||
<pre>{buildNodeHy2Config(node, origin)}</pre>
|
<pre>{buildNodeHy2Config(node, origin)}</pre>
|
||||||
<a
|
<a
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
})}
|
})}
|
||||||
href={`/api/admin/nodes/${node.slug}/config`}
|
href={`/api/admin/nodes/${node.slug}/config`}
|
||||||
>
|
>
|
||||||
下载 hy2 config.yaml
|
下载 hy2 config.yaml
|
||||||
</a>
|
</a>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{node.last_error_message ? (
|
{node.last_error_message ? (
|
||||||
<p className="error-text">{node.last_error_message}</p>
|
<p className="error-text">
|
||||||
) : null}
|
{node.last_error_message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="entity-action-strip">
|
<div className="entity-action-strip">
|
||||||
<ActionForm action={deleteNodeAction}>
|
<ActionForm action={deleteNodeAction}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/subscription";
|
} from "@/lib/subscription";
|
||||||
import {
|
import {
|
||||||
getAdminMirrorUser,
|
getAdminMirrorUser,
|
||||||
|
getNodeProxyName,
|
||||||
getSubscriptionSettings,
|
getSubscriptionSettings,
|
||||||
listSubscriptionNodes,
|
listSubscriptionNodes,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
@@ -25,11 +26,13 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
]);
|
]);
|
||||||
const proxyGroupNodes = nodes.map((node) => ({
|
const proxyGroupNodes = nodes.map((node) => ({
|
||||||
slug: node.slug,
|
slug: node.slug,
|
||||||
label: node.client_name || node.name,
|
label: getNodeProxyName(node),
|
||||||
meta:
|
meta:
|
||||||
node.client_name && node.client_name !== node.name
|
node.node_type === "custom"
|
||||||
? `${node.name} / ${node.slug}`
|
? `${node.name} / ${node.slug} / 自定义`
|
||||||
: node.slug,
|
: node.client_name && node.client_name !== node.name
|
||||||
|
? `${node.name} / ${node.slug}`
|
||||||
|
: node.slug,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,11 +18,20 @@ export async function GET(
|
|||||||
return new NextResponse("node not found", { status: 404 });
|
return new NextResponse("node not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(buildNodeHy2Config(node, getOriginFromRequest(request)), {
|
if (node.node_type !== "hy2") {
|
||||||
status: 200,
|
return new NextResponse("hy2 config unavailable for custom node", {
|
||||||
headers: {
|
status: 409,
|
||||||
"Content-Type": "application/x-yaml; charset=utf-8",
|
});
|
||||||
"Content-Disposition": `attachment; filename="${slug}.config.yaml"`,
|
}
|
||||||
|
|
||||||
|
return new NextResponse(
|
||||||
|
buildNodeHy2Config(node, getOriginFromRequest(request)),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-yaml; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${slug}.config.yaml"`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { buildSubscriptionConfig, buildSubscriptionHeaders } from "@/lib/subscription";
|
import {
|
||||||
|
buildSubscriptionConfig,
|
||||||
|
buildSubscriptionHeaders,
|
||||||
|
} from "@/lib/subscription";
|
||||||
import {
|
import {
|
||||||
getSubscriptionSettings,
|
getSubscriptionSettings,
|
||||||
getUserBySubscriptionToken,
|
getUserBySubscriptionToken,
|
||||||
@@ -26,10 +29,13 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
body = buildSubscriptionConfig({ user, nodes, settings });
|
body = buildSubscriptionConfig({ user, nodes, settings });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response("subscription secret unavailable", { status: 409 });
|
return new Response("subscription configuration unavailable", {
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileName = user.subscription_label || settings.profileName || user.username;
|
const profileName =
|
||||||
|
user.subscription_label || settings.profileName || user.username;
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1317,3 +1317,10 @@ details[open] > .group-summary::before {
|
|||||||
height: auto;
|
height: auto;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mono-textarea {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -89,6 +91,58 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
@@ -641,6 +695,8 @@
|
|||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
@@ -709,6 +765,8 @@
|
|||||||
|
|
||||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||||
@@ -1117,6 +1175,8 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||||
|
|
||||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
@@ -1169,6 +1229,8 @@
|
|||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function ActionForm({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<"form">, "action"> & {
|
}: Omit<ComponentProps<"form">, "action"> & {
|
||||||
action: (data: FormData) => Promise<void>;
|
action: (data: FormData) => Promise<void | { error: string }>;
|
||||||
}) {
|
}) {
|
||||||
const preserveValues = useRef(false);
|
const preserveValues = useRef(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
@@ -42,7 +42,13 @@ export function ActionForm({
|
|||||||
setMessage("");
|
setMessage("");
|
||||||
preserveValues.current = false;
|
preserveValues.current = false;
|
||||||
try {
|
try {
|
||||||
await action(data);
|
const result = await action(data);
|
||||||
|
if (result?.error) {
|
||||||
|
preserveValues.current = true;
|
||||||
|
setFailed(true);
|
||||||
|
setMessage(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
setMessage("已保存");
|
setMessage("已保存");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { parseDocument, stringify } from "yaml";
|
||||||
|
|
||||||
|
export class CustomProxyValidationError extends Error {}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept a single proxy, a one-item list, or the legacy `proxies:` wrapper. */
|
||||||
|
export function parseCustomProxyYaml(
|
||||||
|
source: string,
|
||||||
|
): Record<string, unknown> & { name: string; type: string } {
|
||||||
|
if (!source.trim()) throw new CustomProxyValidationError("请填写节点 YAML。");
|
||||||
|
if (source.length > 128 * 1024)
|
||||||
|
throw new CustomProxyValidationError("单个节点 YAML 不能超过 128 KiB。");
|
||||||
|
const doc = parseDocument(source, { prettyErrors: false, uniqueKeys: true });
|
||||||
|
if (doc.errors.length || doc.warnings.length) {
|
||||||
|
// Parser messages can contain credentials from the YAML; do not echo them.
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"YAML 格式错误,请检查缩进、引号和重复字段。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = doc.toJS({ maxAliasCount: 100 });
|
||||||
|
} catch {
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"YAML 引用无效或过多,请使用独立的节点配置。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isRecord(value) && "proxies" in value) value = value.proxies;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length !== 1)
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"每条节点记录只能包含一个 proxy,请将多个节点分别添加。",
|
||||||
|
);
|
||||||
|
value = value[0];
|
||||||
|
}
|
||||||
|
if (!isRecord(value))
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"请填写一个 Clash proxy 对象或单节点列表。",
|
||||||
|
);
|
||||||
|
if (typeof value.name !== "string" || !value.name.trim()) {
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"节点 YAML 必须包含非空的 name 字段(字符串)。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof value.type !== "string" || !value.type.trim()) {
|
||||||
|
throw new CustomProxyValidationError(
|
||||||
|
"节点 YAML 必须包含非空的 type 字段,例如 anytls。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown> & { name: string; type: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-serialize only for output; stored source and comments remain untouched. */
|
||||||
|
export function renderCustomProxyYaml(source: string) {
|
||||||
|
return stringify([parseCustomProxyYaml(source)], { lineWidth: 0 }).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProxyNameFromYaml(source: string) {
|
||||||
|
try {
|
||||||
|
return parseCustomProxyYaml(source).name;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
// Version 4 matches the schema already deployed on us3. Never reuse its number.
|
||||||
|
function ensureCustomNodeColumns(db: Database.Database) {
|
||||||
|
const columns = new Set(
|
||||||
|
(db.pragma("table_info(hy2_nodes)") as { name: string }[]).map(
|
||||||
|
(column) => column.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!columns.has("node_type"))
|
||||||
|
db.exec(
|
||||||
|
"ALTER TABLE hy2_nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'hy2'",
|
||||||
|
);
|
||||||
|
if (!columns.has("custom_config_yaml"))
|
||||||
|
db.exec(
|
||||||
|
"ALTER TABLE hy2_nodes ADD COLUMN custom_config_yaml TEXT NOT NULL DEFAULT ''",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
||||||
|
`
|
||||||
|
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';
|
||||||
|
`,
|
||||||
|
ensureCustomNodeColumns,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function migrateDatabase(db: Database.Database) {
|
||||||
|
// Read the version under the same write lock as the migration to avoid races.
|
||||||
|
db.transaction(() => {
|
||||||
|
const currentVersion = db.pragma("user_version", {
|
||||||
|
simple: true,
|
||||||
|
}) as number;
|
||||||
|
if (currentVersion > MIGRATIONS.length)
|
||||||
|
throw new Error("数据库版本高于当前程序,请使用匹配的程序版本。");
|
||||||
|
for (let index = currentVersion; index < MIGRATIONS.length; index += 1) {
|
||||||
|
const migration = MIGRATIONS[index];
|
||||||
|
if (typeof migration === "string") db.exec(migration);
|
||||||
|
else migration(db);
|
||||||
|
db.pragma(`user_version = ${index + 1}`);
|
||||||
|
}
|
||||||
|
// Also repairs a partially applied v4 without rewriting any existing data.
|
||||||
|
ensureCustomNodeColumns(db);
|
||||||
|
}).immediate();
|
||||||
|
}
|
||||||
@@ -1,166 +1,15 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import { migrateDatabase } from "@/lib/db-migrations";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { encryptText } from "@/lib/crypto";
|
import { encryptText } from "@/lib/crypto";
|
||||||
import { getEnv } from "@/lib/env";
|
import { getEnv } from "@/lib/env";
|
||||||
import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password";
|
import {
|
||||||
|
createOpaqueToken,
|
||||||
const MIGRATIONS = [
|
createStableId,
|
||||||
`
|
hashPassword,
|
||||||
CREATE TABLE IF NOT EXISTS admins (
|
} from "@/lib/password";
|
||||||
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 {
|
declare global {
|
||||||
var __hy2PanelDb: Database.Database | undefined;
|
var __hy2PanelDb: Database.Database | undefined;
|
||||||
@@ -183,12 +32,7 @@ function createDbInstance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeDb(db: Database.Database) {
|
async function initializeDb(db: Database.Database) {
|
||||||
const currentVersion = db.pragma("user_version", { simple: true }) as number;
|
migrateDatabase(db);
|
||||||
|
|
||||||
for (let index = currentVersion; index < MIGRATIONS.length; index += 1) {
|
|
||||||
db.exec(MIGRATIONS[index]);
|
|
||||||
db.pragma(`user_version = ${index + 1}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -263,7 +107,8 @@ async function initializeDb(db: Database.Database) {
|
|||||||
username: env.ADMIN_USERNAME,
|
username: env.ADMIN_USERNAME,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
secret: encryptText(env.ADMIN_PASSWORD),
|
secret: encryptText(env.ADMIN_PASSWORD),
|
||||||
subscriptionToken: existingAdminUser.subscription_token ?? createOpaqueToken(),
|
subscriptionToken:
|
||||||
|
existingAdminUser.subscription_token ?? createOpaqueToken(),
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -291,7 +136,9 @@ async function initializeDb(db: Database.Database) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const usersMissingTokens = db
|
const usersMissingTokens = db
|
||||||
.prepare("SELECT id, username FROM hy2_users WHERE subscription_token IS NULL")
|
.prepare(
|
||||||
|
"SELECT id, username FROM hy2_users WHERE subscription_token IS NULL",
|
||||||
|
)
|
||||||
.all() as Array<{ id: number; username: string }>;
|
.all() as Array<{ id: number; username: string }>;
|
||||||
|
|
||||||
const updateUserToken = db.prepare(
|
const updateUserToken = db.prepare(
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export async function verifyNodeToken(slug: string, token: string | null) {
|
|||||||
return { ok: false, reason: "node_disabled" as const, node };
|
return { ok: false, reason: "node_disabled" as const, node };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.node_type !== "hy2") {
|
||||||
|
return { ok: false, reason: "node_not_hy2" as const, node };
|
||||||
|
}
|
||||||
|
|
||||||
if (!token || token !== node.auth_token) {
|
if (!token || token !== node.auth_token) {
|
||||||
return { ok: false, reason: "node_token_invalid" as const, node };
|
return { ok: false, reason: "node_token_invalid" as const, node };
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-43
@@ -1,9 +1,17 @@
|
|||||||
|
import {
|
||||||
|
parseCustomProxyYaml,
|
||||||
|
extractProxyNameFromYaml,
|
||||||
|
} from "@/lib/custom-proxy";
|
||||||
import type Database from "better-sqlite3";
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
import { encryptText } from "@/lib/crypto";
|
import { encryptText } from "@/lib/crypto";
|
||||||
import { ensureDb, getDb } from "@/lib/db";
|
import { ensureDb, getDb } from "@/lib/db";
|
||||||
import { getEnv } from "@/lib/env";
|
import { getEnv } from "@/lib/env";
|
||||||
import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password";
|
import {
|
||||||
|
createOpaqueToken,
|
||||||
|
createStableId,
|
||||||
|
hashPassword,
|
||||||
|
} from "@/lib/password";
|
||||||
|
|
||||||
export type Hy2UserRow = {
|
export type Hy2UserRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,8 +32,11 @@ export type Hy2UserRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NodeType = "hy2" | "custom";
|
||||||
|
|
||||||
export type Hy2NodeRow = {
|
export type Hy2NodeRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
node_type: NodeType;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
auth_token: string;
|
auth_token: string;
|
||||||
@@ -40,6 +51,7 @@ export type Hy2NodeRow = {
|
|||||||
bandwidth_down_mbps: number;
|
bandwidth_down_mbps: number;
|
||||||
skip_cert_verify: number;
|
skip_cert_verify: number;
|
||||||
sni: string | null;
|
sni: string | null;
|
||||||
|
custom_config_yaml: string;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
poll_interval_seconds: number;
|
poll_interval_seconds: number;
|
||||||
last_polled_at: string | null;
|
last_polled_at: string | null;
|
||||||
@@ -114,7 +126,11 @@ export function toDatetimeLocalValue(value: string | null) {
|
|||||||
return value.slice(0, 16);
|
return value.slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNodeAuthUrl(slug: string, token: string, origin = getEnv().APP_URL) {
|
export function buildNodeAuthUrl(
|
||||||
|
slug: string,
|
||||||
|
token: string,
|
||||||
|
origin = getEnv().APP_URL,
|
||||||
|
) {
|
||||||
return `${origin}/api/hy2/nodes/${encodeURIComponent(slug)}/auth?token=${encodeURIComponent(token)}`;
|
return `${origin}/api/hy2/nodes/${encodeURIComponent(slug)}/auth?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +140,43 @@ export function buildTrafficStatsUrl(serverHost: string, listen: string) {
|
|||||||
return `http://${serverHost}:${port}`;
|
return `http://${serverHost}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeNodeType(value: string | null | undefined): NodeType {
|
||||||
|
return value === "custom" ? "custom" : "hy2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProxyName(node: {
|
||||||
|
node_type: NodeType;
|
||||||
|
custom_config_yaml: string;
|
||||||
|
client_name: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
if (node.node_type === "custom") {
|
||||||
|
return (
|
||||||
|
extractProxyNameFromYaml(node.custom_config_yaml) ||
|
||||||
|
node.client_name ||
|
||||||
|
node.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.client_name || node.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(value: number) {
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
||||||
|
return 443;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: number, fallback: number) {
|
||||||
|
if (!Number.isInteger(value) || value < 1) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserLifecycleState(user: {
|
export function getUserLifecycleState(user: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
@@ -153,9 +206,7 @@ export function getUserLifecycleState(user: {
|
|||||||
|
|
||||||
export async function getAdminByUsername(username: string) {
|
export async function getAdminByUsername(username: string) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db.prepare("SELECT * FROM admins WHERE username = ?").get(username) as
|
||||||
.prepare("SELECT * FROM admins WHERE username = ?")
|
|
||||||
.get(username) as
|
|
||||||
| {
|
| {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -190,16 +241,14 @@ export async function getUserByUsername(username: string) {
|
|||||||
|
|
||||||
export async function getUserById(id: number) {
|
export async function getUserById(id: number) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db.prepare("SELECT * FROM hy2_users WHERE id = ?").get(id) as
|
||||||
.prepare("SELECT * FROM hy2_users WHERE id = ?")
|
Hy2UserRow | undefined;
|
||||||
.get(id) as Hy2UserRow | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByAuthId(authId: string) {
|
export async function getUserByAuthId(authId: string) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db.prepare("SELECT * FROM hy2_users WHERE auth_id = ?").get(authId) as
|
||||||
.prepare("SELECT * FROM hy2_users WHERE auth_id = ?")
|
Hy2UserRow | undefined;
|
||||||
.get(authId) as Hy2UserRow | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserBySubscriptionToken(token: string) {
|
export async function getUserBySubscriptionToken(token: string) {
|
||||||
@@ -334,12 +383,12 @@ export async function listNodes() {
|
|||||||
|
|
||||||
export async function getNodeBySlug(slug: string) {
|
export async function getNodeBySlug(slug: string) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db.prepare("SELECT * FROM hy2_nodes WHERE slug = ?").get(slug) as
|
||||||
.prepare("SELECT * FROM hy2_nodes WHERE slug = ?")
|
Hy2NodeRow | undefined;
|
||||||
.get(slug) as Hy2NodeRow | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNode(input: {
|
export async function createNode(input: {
|
||||||
|
nodeType?: NodeType;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
@@ -351,6 +400,7 @@ export async function createNode(input: {
|
|||||||
bandwidthDownMbps: number;
|
bandwidthDownMbps: number;
|
||||||
skipCertVerify: boolean;
|
skipCertVerify: boolean;
|
||||||
sni: string | null;
|
sni: string | null;
|
||||||
|
customConfigYaml?: string;
|
||||||
trafficStatsUrl: string;
|
trafficStatsUrl: string;
|
||||||
trafficStatsListen: string;
|
trafficStatsListen: string;
|
||||||
trafficStatsSecret: string;
|
trafficStatsSecret: string;
|
||||||
@@ -359,46 +409,67 @@ export async function createNode(input: {
|
|||||||
}) {
|
}) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
const now = dbNow();
|
const now = dbNow();
|
||||||
|
const nodeType = normalizeNodeType(input.nodeType);
|
||||||
|
const serverPort = normalizePort(input.serverPort);
|
||||||
|
const trafficStatsListen = input.trafficStatsListen || ":9999";
|
||||||
|
const customConfigYaml =
|
||||||
|
nodeType === "custom" ? (input.customConfigYaml ?? "") : "";
|
||||||
|
|
||||||
|
if (nodeType === "custom") parseCustomProxyYaml(customConfigYaml);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO hy2_nodes (
|
INSERT INTO hy2_nodes (
|
||||||
slug, name, auth_token, hy2_listen, server_host, server_port, client_name,
|
node_type, slug, name, auth_token, hy2_listen, server_host, server_port, client_name,
|
||||||
bandwidth_up_mbps, bandwidth_down_mbps, skip_cert_verify, sni,
|
bandwidth_up_mbps, bandwidth_down_mbps, skip_cert_verify, sni,
|
||||||
traffic_stats_url, traffic_stats_listen, traffic_stats_secret, enabled,
|
custom_config_yaml, traffic_stats_url, traffic_stats_listen, traffic_stats_secret, enabled,
|
||||||
poll_interval_seconds, created_at, updated_at
|
poll_interval_seconds, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@slug, @name, @authToken, @hy2Listen, @serverHost, @serverPort, @clientName,
|
@nodeType, @slug, @name, @authToken, @hy2Listen, @serverHost, @serverPort, @clientName,
|
||||||
@bandwidthUpMbps, @bandwidthDownMbps, @skipCertVerify, @sni,
|
@bandwidthUpMbps, @bandwidthDownMbps, @skipCertVerify, @sni,
|
||||||
@trafficStatsUrl, @trafficStatsListen, @trafficStatsSecret, @enabled,
|
@customConfigYaml, @trafficStatsUrl, @trafficStatsListen, @trafficStatsSecret, @enabled,
|
||||||
@pollIntervalSeconds, @now, @now
|
@pollIntervalSeconds, @now, @now
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
).run({
|
).run({
|
||||||
|
nodeType,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
authToken: input.authToken || createOpaqueToken(),
|
authToken: input.authToken || createOpaqueToken(),
|
||||||
hy2Listen: input.hy2Listen || `:${input.serverPort}`,
|
hy2Listen: input.hy2Listen || `:${serverPort}`,
|
||||||
serverHost: input.serverHost,
|
serverHost: input.serverHost,
|
||||||
serverPort: input.serverPort,
|
serverPort,
|
||||||
clientName: input.clientName,
|
clientName:
|
||||||
bandwidthUpMbps: input.bandwidthUpMbps,
|
input.clientName ||
|
||||||
bandwidthDownMbps: input.bandwidthDownMbps,
|
(nodeType === "custom"
|
||||||
|
? extractProxyNameFromYaml(customConfigYaml)
|
||||||
|
: null) ||
|
||||||
|
input.name,
|
||||||
|
bandwidthUpMbps: normalizePositiveInteger(input.bandwidthUpMbps, 30),
|
||||||
|
bandwidthDownMbps: normalizePositiveInteger(input.bandwidthDownMbps, 30),
|
||||||
skipCertVerify: input.skipCertVerify ? 1 : 0,
|
skipCertVerify: input.skipCertVerify ? 1 : 0,
|
||||||
sni: input.sni,
|
sni: input.sni,
|
||||||
|
customConfigYaml,
|
||||||
trafficStatsUrl:
|
trafficStatsUrl:
|
||||||
input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen),
|
nodeType === "custom"
|
||||||
trafficStatsListen: input.trafficStatsListen,
|
? ""
|
||||||
|
: input.trafficStatsUrl ||
|
||||||
|
buildTrafficStatsUrl(input.serverHost, trafficStatsListen),
|
||||||
|
trafficStatsListen,
|
||||||
trafficStatsSecret: input.trafficStatsSecret || createOpaqueToken(),
|
trafficStatsSecret: input.trafficStatsSecret || createOpaqueToken(),
|
||||||
enabled: input.enabled ? 1 : 0,
|
enabled: input.enabled ? 1 : 0,
|
||||||
pollIntervalSeconds: input.pollIntervalSeconds,
|
pollIntervalSeconds: normalizePositiveInteger(
|
||||||
|
input.pollIntervalSeconds,
|
||||||
|
15,
|
||||||
|
),
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNode(input: {
|
export async function updateNode(input: {
|
||||||
id: number;
|
id: number;
|
||||||
|
nodeType?: NodeType;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
authToken: string;
|
authToken: string;
|
||||||
@@ -410,6 +481,7 @@ export async function updateNode(input: {
|
|||||||
bandwidthDownMbps: number;
|
bandwidthDownMbps: number;
|
||||||
skipCertVerify: boolean;
|
skipCertVerify: boolean;
|
||||||
sni: string | null;
|
sni: string | null;
|
||||||
|
customConfigYaml?: string;
|
||||||
trafficStatsUrl: string;
|
trafficStatsUrl: string;
|
||||||
trafficStatsListen: string;
|
trafficStatsListen: string;
|
||||||
trafficStatsSecret: string;
|
trafficStatsSecret: string;
|
||||||
@@ -417,13 +489,25 @@ export async function updateNode(input: {
|
|||||||
pollIntervalSeconds: number;
|
pollIntervalSeconds: number;
|
||||||
}) {
|
}) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
|
const nodeType = normalizeNodeType(input.nodeType);
|
||||||
|
const serverPort = normalizePort(input.serverPort);
|
||||||
|
const trafficStatsListen = input.trafficStatsListen || ":9999";
|
||||||
|
const customConfigYaml =
|
||||||
|
nodeType === "custom" ? (input.customConfigYaml ?? "") : "";
|
||||||
|
|
||||||
|
if (nodeType === "custom") parseCustomProxyYaml(customConfigYaml);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE hy2_nodes
|
UPDATE hy2_nodes
|
||||||
SET
|
SET
|
||||||
|
node_type = @nodeType,
|
||||||
slug = @slug,
|
slug = @slug,
|
||||||
name = @name,
|
name = @name,
|
||||||
auth_token = @authToken,
|
auth_token = CASE
|
||||||
|
WHEN @authToken = '' THEN auth_token
|
||||||
|
ELSE @authToken
|
||||||
|
END,
|
||||||
hy2_listen = @hy2Listen,
|
hy2_listen = @hy2Listen,
|
||||||
server_host = @serverHost,
|
server_host = @serverHost,
|
||||||
server_port = @serverPort,
|
server_port = @serverPort,
|
||||||
@@ -432,6 +516,7 @@ export async function updateNode(input: {
|
|||||||
bandwidth_down_mbps = @bandwidthDownMbps,
|
bandwidth_down_mbps = @bandwidthDownMbps,
|
||||||
skip_cert_verify = @skipCertVerify,
|
skip_cert_verify = @skipCertVerify,
|
||||||
sni = @sni,
|
sni = @sni,
|
||||||
|
custom_config_yaml = @customConfigYaml,
|
||||||
traffic_stats_url = @trafficStatsUrl,
|
traffic_stats_url = @trafficStatsUrl,
|
||||||
traffic_stats_listen = @trafficStatsListen,
|
traffic_stats_listen = @trafficStatsListen,
|
||||||
traffic_stats_secret = CASE
|
traffic_stats_secret = CASE
|
||||||
@@ -445,9 +530,28 @@ export async function updateNode(input: {
|
|||||||
`,
|
`,
|
||||||
).run({
|
).run({
|
||||||
...input,
|
...input,
|
||||||
hy2Listen: input.hy2Listen || `:${input.serverPort}`,
|
nodeType,
|
||||||
|
hy2Listen: input.hy2Listen || `:${serverPort}`,
|
||||||
|
serverPort,
|
||||||
|
clientName:
|
||||||
|
input.clientName ||
|
||||||
|
(nodeType === "custom"
|
||||||
|
? extractProxyNameFromYaml(customConfigYaml)
|
||||||
|
: null) ||
|
||||||
|
input.name,
|
||||||
|
bandwidthUpMbps: normalizePositiveInteger(input.bandwidthUpMbps, 30),
|
||||||
|
bandwidthDownMbps: normalizePositiveInteger(input.bandwidthDownMbps, 30),
|
||||||
|
customConfigYaml,
|
||||||
trafficStatsUrl:
|
trafficStatsUrl:
|
||||||
input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen),
|
nodeType === "custom"
|
||||||
|
? ""
|
||||||
|
: input.trafficStatsUrl ||
|
||||||
|
buildTrafficStatsUrl(input.serverHost, trafficStatsListen),
|
||||||
|
trafficStatsListen,
|
||||||
|
pollIntervalSeconds: normalizePositiveInteger(
|
||||||
|
input.pollIntervalSeconds,
|
||||||
|
15,
|
||||||
|
),
|
||||||
enabled: input.enabled ? 1 : 0,
|
enabled: input.enabled ? 1 : 0,
|
||||||
skipCertVerify: input.skipCertVerify ? 1 : 0,
|
skipCertVerify: input.skipCertVerify ? 1 : 0,
|
||||||
updatedAt: dbNow(),
|
updatedAt: dbNow(),
|
||||||
@@ -569,7 +673,11 @@ function getSeriesMapKey(bucketStart: string, nodeId: number) {
|
|||||||
return `${bucketStart}:${nodeId}`;
|
return `${bucketStart}:${nodeId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrafficSeriesMapKey(bucketStart: string, nodeId: number, authId: string) {
|
function getTrafficSeriesMapKey(
|
||||||
|
bucketStart: string,
|
||||||
|
nodeId: number,
|
||||||
|
authId: string,
|
||||||
|
) {
|
||||||
return `${bucketStart}|${nodeId}|${authId}`;
|
return `${bucketStart}|${nodeId}|${authId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +734,7 @@ export async function getDashboardMetrics() {
|
|||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
|
node_type,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -640,6 +749,7 @@ export async function getDashboardMetrics() {
|
|||||||
)
|
)
|
||||||
.all() as Array<{
|
.all() as Array<{
|
||||||
id: number;
|
id: number;
|
||||||
|
node_type: NodeType;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
@@ -801,7 +911,10 @@ export async function getDashboardMetrics() {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
const loadBucketSet = new Set(hourlyBuckets);
|
const loadBucketSet = new Set(hourlyBuckets);
|
||||||
const loadMap = new Map<string, { online_users: number; stream_count: number }>();
|
const loadMap = new Map<
|
||||||
|
string,
|
||||||
|
{ online_users: number; stream_count: number }
|
||||||
|
>();
|
||||||
|
|
||||||
for (const row of loadRows) {
|
for (const row of loadRows) {
|
||||||
const bucketStart = getUtcBucketKey(row.finished_at, "hour");
|
const bucketStart = getUtcBucketKey(row.finished_at, "hour");
|
||||||
@@ -864,7 +977,9 @@ export async function getDashboardMetrics() {
|
|||||||
failureMap.set(key, (failureMap.get(key) ?? 0) + 1);
|
failureMap.set(key, (failureMap.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const failureNodeIds = trackedNodeIds.includes(0) ? trackedNodeIds : [0, ...trackedNodeIds];
|
const failureNodeIds = trackedNodeIds.includes(0)
|
||||||
|
? trackedNodeIds
|
||||||
|
: [0, ...trackedNodeIds];
|
||||||
const failureTrend: DashboardFailureTrendRow[] = [];
|
const failureTrend: DashboardFailureTrendRow[] = [];
|
||||||
|
|
||||||
for (const bucketStart of dailyBuckets) {
|
for (const bucketStart of dailyBuckets) {
|
||||||
@@ -1051,12 +1166,16 @@ export async function touchUserSession(sessionId: string) {
|
|||||||
|
|
||||||
export async function deleteUserSessionByHash(sessionHash: string) {
|
export async function deleteUserSessionByHash(sessionHash: string) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
db.prepare("DELETE FROM user_sessions WHERE session_hash = ?").run(sessionHash);
|
db.prepare("DELETE FROM user_sessions WHERE session_hash = ?").run(
|
||||||
|
sessionHash,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAdminSessionByHash(sessionHash: string) {
|
export async function deleteAdminSessionByHash(sessionHash: string) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
db.prepare("DELETE FROM admin_sessions WHERE session_hash = ?").run(sessionHash);
|
db.prepare("DELETE FROM admin_sessions WHERE session_hash = ?").run(
|
||||||
|
sessionHash,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubscriptionSettings() {
|
export async function getSubscriptionSettings() {
|
||||||
@@ -1073,7 +1192,9 @@ export async function getSubscriptionSettings() {
|
|||||||
mainProxyGroupMemberSlugs: JSON.parse(
|
mainProxyGroupMemberSlugs: JSON.parse(
|
||||||
map.get("main_proxy_group_member_slugs_json") ?? "[]",
|
map.get("main_proxy_group_member_slugs_json") ?? "[]",
|
||||||
) as string[],
|
) as string[],
|
||||||
proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[],
|
proxyGroups: JSON.parse(
|
||||||
|
map.get("proxy_groups_json") ?? "[]",
|
||||||
|
) as ProxyGroupConfig[],
|
||||||
} satisfies SubscriptionSettings;
|
} satisfies SubscriptionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,7 +1290,7 @@ export async function listEnabledNodesForPolling() {
|
|||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM hy2_nodes
|
FROM hy2_nodes
|
||||||
WHERE enabled = 1
|
WHERE enabled = 1 AND node_type = 'hy2'
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -1186,7 +1307,10 @@ export async function shouldPollNode(nodeId: number, intervalSeconds: number) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Date.now() - new Date(row.last_polled_at).getTime() >= intervalSeconds * 1000;
|
return (
|
||||||
|
Date.now() - new Date(row.last_polled_at).getTime() >=
|
||||||
|
intervalSeconds * 1000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyNodeSync(
|
export function applyNodeSync(
|
||||||
@@ -1211,14 +1335,19 @@ export function applyNodeSync(
|
|||||||
`
|
`
|
||||||
SELECT id, auth_id
|
SELECT id, auth_id
|
||||||
FROM hy2_users
|
FROM hy2_users
|
||||||
WHERE auth_id IN (${Array.from(authIds)
|
WHERE auth_id IN (${
|
||||||
.map(() => "?")
|
Array.from(authIds)
|
||||||
.join(",") || "''"})
|
.map(() => "?")
|
||||||
|
.join(",") || "''"
|
||||||
|
})
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const users = authIds.size
|
const users = authIds.size
|
||||||
? (lookupUsers.all(...Array.from(authIds)) as Array<{ id: number; auth_id: string }>)
|
? (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 userMap = new Map(users.map((user) => [user.auth_id, user.id]));
|
||||||
@@ -1329,7 +1458,11 @@ export function applyNodeSync(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeNodeSyncError(nodeId: number, startedAt: string, errorMessage: string) {
|
export async function writeNodeSyncError(
|
||||||
|
nodeId: number,
|
||||||
|
startedAt: string,
|
||||||
|
errorMessage: string,
|
||||||
|
) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
const now = dbNow();
|
const now = dbNow();
|
||||||
|
|
||||||
@@ -1382,7 +1515,9 @@ export async function getKickCandidates(authIds: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runInTransaction<T>(callback: (db: Database.Database) => T) {
|
export async function runInTransaction<T>(
|
||||||
|
callback: (db: Database.Database) => T,
|
||||||
|
) {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
const transaction = db.transaction(callback);
|
const transaction = db.transaction(callback);
|
||||||
return transaction(getDb());
|
return transaction(getDb());
|
||||||
|
|||||||
+60
-25
@@ -1,14 +1,11 @@
|
|||||||
|
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { decryptText } from "@/lib/crypto";
|
import { decryptText } from "@/lib/crypto";
|
||||||
import { getEnv } from "@/lib/env";
|
import { getEnv } from "@/lib/env";
|
||||||
import type {
|
import type { Hy2NodeRow, Hy2UserRow, ProxyGroupConfig } from "@/lib/store";
|
||||||
Hy2NodeRow,
|
import { buildNodeAuthUrl, formatBytes, getNodeProxyName } from "@/lib/store";
|
||||||
Hy2UserRow,
|
|
||||||
ProxyGroupConfig,
|
|
||||||
} from "@/lib/store";
|
|
||||||
import { buildNodeAuthUrl, formatBytes } from "@/lib/store";
|
|
||||||
|
|
||||||
type ClashSettings = {
|
type ClashSettings = {
|
||||||
template: string;
|
template: string;
|
||||||
@@ -31,13 +28,13 @@ function indent(text: string, spaces: number) {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProxy(node: Hy2NodeRow, authString: string) {
|
function renderHy2Proxy(node: Hy2NodeRow, authString: string) {
|
||||||
const lines = [
|
const lines = [
|
||||||
`- name: "${node.client_name || node.name}"`,
|
`- name: ${JSON.stringify(node.client_name || node.name)}`,
|
||||||
" type: hysteria2",
|
" type: hysteria2",
|
||||||
` server: ${node.server_host}`,
|
` server: ${node.server_host}`,
|
||||||
` port: ${node.server_port}`,
|
` port: ${node.server_port}`,
|
||||||
` password: "${authString}"`,
|
` password: ${JSON.stringify(authString)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (node.sni) {
|
if (node.sni) {
|
||||||
@@ -55,10 +52,30 @@ function renderProxy(node: Hy2NodeRow, authString: string) {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) {
|
||||||
|
if (node.node_type === "custom") {
|
||||||
|
return renderCustomProxyYaml(node.custom_config_yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderHy2Proxy(node, authString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleSubscriptionNode(node: Hy2NodeRow) {
|
||||||
|
if (!node.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.node_type === "custom") {
|
||||||
|
return Boolean(node.custom_config_yaml.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(node.server_host && node.server_port > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function renderGroupEntry(name: string, proxyNames: string[]) {
|
function renderGroupEntry(name: string, proxyNames: string[]) {
|
||||||
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
|
const quoted = proxyNames.map((name) => JSON.stringify(name)).join(", ");
|
||||||
return [
|
return [
|
||||||
` - name: ${name}`,
|
` - name: ${JSON.stringify(name)}`,
|
||||||
" type: select",
|
" type: select",
|
||||||
` proxies: [${quoted}]`,
|
` proxies: [${quoted}]`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -94,7 +111,10 @@ function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) {
|
|||||||
|
|
||||||
export function encodeRFC5987ValueChars(value: string) {
|
export function encodeRFC5987ValueChars(value: string) {
|
||||||
return encodeURIComponent(value)
|
return encodeURIComponent(value)
|
||||||
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
.replace(
|
||||||
|
/['()]/g,
|
||||||
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
)
|
||||||
.replace(/\*/g, "%2A")
|
.replace(/\*/g, "%2A")
|
||||||
.replace(/%(7C|60|5E)/g, (_, code) =>
|
.replace(/%(7C|60|5E)/g, (_, code) =>
|
||||||
String.fromCharCode(Number.parseInt(code, 16)),
|
String.fromCharCode(Number.parseInt(code, 16)),
|
||||||
@@ -112,7 +132,10 @@ function buildAsciiFilenameFallback(value: string) {
|
|||||||
return ascii || "subscription";
|
return ascii || "subscription";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) {
|
export function buildSubscriptionHeaders(
|
||||||
|
user: Hy2UserRow,
|
||||||
|
profileName: string,
|
||||||
|
) {
|
||||||
const expire = user.expires_at
|
const expire = user.expires_at
|
||||||
? Math.round(new Date(user.expires_at).getTime() / 1000)
|
? Math.round(new Date(user.expires_at).getTime() / 1000)
|
||||||
: user.is_admin
|
: user.is_admin
|
||||||
@@ -137,7 +160,10 @@ export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) {
|
|||||||
return `${origin}/api/subscription/${token}/clash`;
|
return `${origin}/api/subscription/${token}/clash`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) {
|
export function buildNodeHy2Config(
|
||||||
|
node: Hy2NodeRow,
|
||||||
|
origin = getEnv().APP_URL,
|
||||||
|
) {
|
||||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -161,25 +187,34 @@ export function buildSubscriptionConfig(input: {
|
|||||||
nodes: Hy2NodeRow[];
|
nodes: Hy2NodeRow[];
|
||||||
settings: ClashSettings;
|
settings: ClashSettings;
|
||||||
}) {
|
}) {
|
||||||
const authPassword = decryptText(input.user.auth_secret_encrypted);
|
const eligibleNodes = input.nodes.filter((node) =>
|
||||||
if (!authPassword) {
|
isEligibleSubscriptionNode(node),
|
||||||
|
);
|
||||||
|
const needsUserAuth = eligibleNodes.some((node) => node.node_type === "hy2");
|
||||||
|
const authPassword = needsUserAuth
|
||||||
|
? decryptText(input.user.auth_secret_encrypted)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (needsUserAuth && !authPassword) {
|
||||||
throw new Error("user_secret_unavailable");
|
throw new Error("user_secret_unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const authString = `${input.user.username}:${authPassword}`;
|
const authString = authPassword
|
||||||
const eligibleNodes = input.nodes.filter(
|
? `${input.user.username}:${authPassword}`
|
||||||
(node) => node.enabled && node.server_host && node.server_port > 0,
|
: "";
|
||||||
);
|
|
||||||
const orderedEligibleNodes = orderNodesBySlugs(
|
const orderedEligibleNodes = orderNodesBySlugs(
|
||||||
eligibleNodes,
|
eligibleNodes,
|
||||||
input.settings.mainProxyGroupMemberSlugs,
|
input.settings.mainProxyGroupMemberSlugs,
|
||||||
);
|
);
|
||||||
const nodeNameBySlug = new Map(
|
const nodeNameBySlug = new Map(
|
||||||
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
|
eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]),
|
||||||
);
|
);
|
||||||
const proxyNames = orderedEligibleNodes.map((node) => node.client_name || node.name);
|
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
|
||||||
const proxiesBlock = orderedEligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
|
const proxiesBlock = orderedEligibleNodes
|
||||||
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
|
.map((node) => renderSubscriptionProxy(node, authString))
|
||||||
|
.join("\n");
|
||||||
|
const defaultTemplate =
|
||||||
|
input.settings.template.trimEnd() || readDefaultTemplate();
|
||||||
const renderedGroups = [
|
const renderedGroups = [
|
||||||
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
|
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
|
||||||
...input.settings.proxyGroups
|
...input.settings.proxyGroups
|
||||||
@@ -202,7 +237,7 @@ export function buildSubscriptionConfig(input: {
|
|||||||
return [
|
return [
|
||||||
defaultTemplate,
|
defaultTemplate,
|
||||||
"",
|
"",
|
||||||
"proxies:",
|
proxiesBlock ? "proxies:" : "proxies: []",
|
||||||
indent(proxiesBlock, 2),
|
indent(proxiesBlock, 2),
|
||||||
"",
|
"",
|
||||||
"proxy-groups:",
|
"proxy-groups:",
|
||||||
|
|||||||
Generated
+535
@@ -22,6 +22,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -353,6 +355,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4839,6 +5283,48 @@
|
|||||||
"tests/browser-compat"
|
"tests/browser-compat"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5367,6 +5853,21 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8127,6 +8628,25 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||||
|
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -8534,6 +9054,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"test": "tsx --test tests/unit/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"ignoreScripts": [
|
"ignoreScripts": [
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const databasePath = process.env.HY2_E2E_DATABASE ??= `/tmp/hy2-panel-ui-test-${Date.now()}.db`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
|
testMatch: "**/*.spec.ts",
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
use: {
|
use: {
|
||||||
@@ -14,7 +17,7 @@ export default defineConfig({
|
|||||||
url: "http://localhost:3106/login",
|
url: "http://localhost:3106/login",
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
env: {
|
env: {
|
||||||
DATABASE_PATH: `/tmp/hy2-panel-ui-test-${Date.now()}.db`,
|
DATABASE_PATH: databasePath,
|
||||||
POLLER_ENABLED: "false",
|
POLLER_ENABLED: "false",
|
||||||
ADMIN_USERNAME: "ui-review",
|
ADMIN_USERNAME: "ui-review",
|
||||||
ADMIN_PASSWORD: "ui-review-local-2026",
|
ADMIN_PASSWORD: "ui-review-local-2026",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test, expect } from "./e2e-fixtures";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
test("custom YAML nodes use compact forms, persist edits and integrate with subscription groups", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/dashboard/);
|
||||||
|
await page.getByRole("link", { name: "节点管理" }).click();
|
||||||
|
const create = page
|
||||||
|
.locator(".compact-create-panel details")
|
||||||
|
.filter({ has: page.getByRole("heading", { name: "新增自定义订阅节点" }) });
|
||||||
|
await create.locator(":scope > summary").click();
|
||||||
|
await create.getByLabel("Slug", { exact: true }).fill("custom-browser");
|
||||||
|
await create.getByLabel("显示名", { exact: true }).fill("香港自定义节点");
|
||||||
|
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill("name: [invalid");
|
||||||
|
await create.getByRole("button", { name: "创建自定义节点" }).click();
|
||||||
|
await expect(create.getByRole("alert")).toContainText("YAML 格式错误");
|
||||||
|
await expect(create.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
|
||||||
|
"name: [invalid",
|
||||||
|
);
|
||||||
|
const source =
|
||||||
|
'# keep this comment\nproxies:\n - name: \'HK "AnyTLS": #1\'\n type: anytls\n server: hk.example.com\n port: 443\n password: "test-only:password"\n udp: true\n';
|
||||||
|
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(source);
|
||||||
|
await create.getByRole("button", { name: "创建自定义节点" }).click();
|
||||||
|
const row = page
|
||||||
|
.locator(".entity-table__item")
|
||||||
|
.filter({ hasText: "custom-browser" });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row).toContainText("不轮询");
|
||||||
|
await row.locator(":scope > summary").click();
|
||||||
|
await expect(row.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
await expect(row.getByLabel("客户端地址", { exact: true })).toHaveCount(0);
|
||||||
|
const updated = source.replace('HK "AnyTLS": #1', 'HK "AnyTLS": #2');
|
||||||
|
await row.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(updated);
|
||||||
|
await row.getByRole("button", { name: "保存自定义节点" }).click();
|
||||||
|
await expect(row.locator("p[role=status]")).toHaveText("已保存");
|
||||||
|
await page.reload();
|
||||||
|
await row.locator(":scope > summary").click();
|
||||||
|
await expect(
|
||||||
|
row.getByRole("textbox", { name: "节点 YAML", exact: true }),
|
||||||
|
).toHaveValue(updated);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/custom-node-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
const config = await page.request.get(
|
||||||
|
"/api/admin/nodes/custom-browser/config",
|
||||||
|
);
|
||||||
|
expect(config.status()).toBe(409);
|
||||||
|
const auth = await page.request.post("/api/hy2/nodes/custom-browser/auth", {
|
||||||
|
data: { auth: "test:test" },
|
||||||
|
});
|
||||||
|
expect(await auth.json()).toMatchObject({ ok: false });
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "订阅配置" }).click();
|
||||||
|
const main = page.locator(".proxy-group-card").first();
|
||||||
|
await expect(main.locator(".proxy-sort-item")).toContainText(
|
||||||
|
'HK "AnyTLS": #2',
|
||||||
|
);
|
||||||
|
const createGroup = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.locator("summary", { hasText: "新建分组" }) });
|
||||||
|
await createGroup.locator(":scope > summary").click();
|
||||||
|
await createGroup.getByLabel("新建分组名").fill("Custom Work");
|
||||||
|
await createGroup.getByRole("button", { name: /HK.*添加/ }).click();
|
||||||
|
await createGroup
|
||||||
|
.getByRole("button", { name: "新建分组", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.locator("summary strong", { hasText: "Custom Work" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const url = await page.locator(".copy-line code").first().textContent();
|
||||||
|
const response = await page.request.get(url!);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const subscription = parse(await response.text());
|
||||||
|
const proxy = subscription.proxies.find(
|
||||||
|
(p: { type: string }) => p.type === "anytls",
|
||||||
|
);
|
||||||
|
expect(proxy).toMatchObject({
|
||||||
|
name: 'HK "AnyTLS": #2',
|
||||||
|
server: "hk.example.com",
|
||||||
|
password: "test-only:password",
|
||||||
|
udp: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
subscription["proxy-groups"].find(
|
||||||
|
(g: { name: string }) => g.name === "Custom Work",
|
||||||
|
).proxies,
|
||||||
|
).toEqual([proxy.name]);
|
||||||
|
const group = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({
|
||||||
|
has: page.locator("summary strong", { hasText: "Custom Work" }),
|
||||||
|
});
|
||||||
|
await group.locator(":scope > summary").click();
|
||||||
|
await group.getByRole("button", { name: "删除分组" }).click();
|
||||||
|
await page.getByRole("button", { name: "确认删除分组" }).click();
|
||||||
|
await expect(group).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "节点管理" }).click();
|
||||||
|
await page.getByRole("searchbox").fill("custom");
|
||||||
|
await expect(page.locator(".entity-table__item:visible")).toHaveCount(1);
|
||||||
|
await row.locator(":scope > summary").click();
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/custom-node-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await row.getByLabel("启用节点", { exact: true }).uncheck();
|
||||||
|
await row.getByRole("button", { name: "保存自定义节点" }).click();
|
||||||
|
await expect(row.locator("p[role=status]")).toHaveText("已保存");
|
||||||
|
const disabled = parse(await (await page.request.get(url!)).text());
|
||||||
|
expect(
|
||||||
|
disabled.proxies.some((p: { type: string }) => p.type === "anytls"),
|
||||||
|
).toBe(false);
|
||||||
|
await row.getByRole("button", { name: "删除节点" }).click();
|
||||||
|
await page.getByRole("button", { name: "确认删除节点" }).click();
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test as base, expect } from "@playwright/test";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export const test = base.extend<{ isolatedData: void }>({
|
||||||
|
isolatedData: [async ({}, use) => {
|
||||||
|
const file = process.env.HY2_E2E_DATABASE;
|
||||||
|
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file)) throw new Error("Refusing to reset a non-test database");
|
||||||
|
const db = new Database(file, { fileMustExist: true });
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec("DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;");
|
||||||
|
db.prepare("UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')").run();
|
||||||
|
})();
|
||||||
|
db.close();
|
||||||
|
await use();
|
||||||
|
}, { auto: true }],
|
||||||
|
});
|
||||||
|
export { expect };
|
||||||
Vendored
+124
@@ -0,0 +1,124 @@
|
|||||||
|
-- Schema only, inspected on us3 on 2026-09-29. No production data.
|
||||||
|
CREATE TABLE 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 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 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
|
||||||
|
, is_admin INTEGER NOT NULL DEFAULT 0, auth_secret_encrypted TEXT, subscription_token TEXT, subscription_label TEXT NOT NULL DEFAULT '');
|
||||||
|
CREATE TABLE 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
|
||||||
|
, server_host TEXT NOT NULL DEFAULT '', server_port INTEGER NOT NULL DEFAULT 443, client_name TEXT NOT NULL DEFAULT '', bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30, bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30, skip_cert_verify INTEGER NOT NULL DEFAULT 0, sni TEXT, traffic_stats_listen TEXT NOT NULL DEFAULT ':9999', hy2_listen TEXT NOT NULL DEFAULT ':443', node_type TEXT NOT NULL DEFAULT 'hy2', custom_config_yaml TEXT NOT NULL DEFAULT '');
|
||||||
|
CREATE TABLE 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 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 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 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 idx_hy2_users_auth_id ON hy2_users(auth_id);
|
||||||
|
CREATE INDEX idx_hy2_users_username ON hy2_users(username);
|
||||||
|
CREATE INDEX idx_hy2_nodes_enabled ON hy2_nodes(enabled);
|
||||||
|
CREATE INDEX idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
|
||||||
|
CREATE INDEX idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
|
||||||
|
CREATE INDEX idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_auth_audits_created_at ON auth_audits(created_at DESC);
|
||||||
|
CREATE TABLE 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 app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_hy2_users_subscription_token ON hy2_users(subscription_token);
|
||||||
|
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
|
||||||
|
PRAGMA user_version = 4;
|
||||||
+11
-9
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./e2e-fixtures";
|
||||||
|
|
||||||
test("compact management flows, drag persistence, filters and mobile layout", async ({
|
test("compact management flows, drag persistence, filters and mobile layout", async ({
|
||||||
page,
|
page,
|
||||||
@@ -22,7 +22,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
|
|||||||
["singapore", "Singapore"],
|
["singapore", "Singapore"],
|
||||||
["frankfurt", "Frankfurt"],
|
["frankfurt", "Frankfurt"],
|
||||||
]) {
|
]) {
|
||||||
const create = page.locator(".compact-create-panel details");
|
const create = page
|
||||||
|
.locator(".compact-create-panel details")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }),
|
||||||
|
});
|
||||||
if ((await create.getAttribute("open")) === null)
|
if ((await create.getAttribute("open")) === null)
|
||||||
await create.locator("summary").click();
|
await create.locator("summary").click();
|
||||||
await create.getByLabel("Slug", { exact: true }).fill(slug);
|
await create.getByLabel("Slug", { exact: true }).fill(slug);
|
||||||
@@ -158,13 +162,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
]) {
|
]) {
|
||||||
await page.goto(`/admin/${route}`);
|
await page.goto(`/admin/${route}`);
|
||||||
await expect
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
.poll(() =>
|
width: document.documentElement.scrollWidth,
|
||||||
page.evaluate(
|
viewport: innerWidth,
|
||||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
overflowing: [...document.querySelectorAll("body *")].filter(el => el.getBoundingClientRect().right > innerWidth && getComputedStyle(el).position !== "absolute").slice(0, 6).map(el => ({ tag: el.tagName, class: el.className, right: el.getBoundingClientRect().right })),
|
||||||
),
|
}))).toMatchObject({ width: 390, viewport: 390 });
|
||||||
)
|
|
||||||
.toBe(true);
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/${route}-mobile.png`,
|
path: `test-results/${route}-mobile.png`,
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
import {
|
||||||
|
parseCustomProxyYaml,
|
||||||
|
renderCustomProxyYaml,
|
||||||
|
extractProxyNameFromYaml,
|
||||||
|
} from "../../lib/custom-proxy";
|
||||||
|
import { buildSubscriptionConfig } from "../../lib/subscription";
|
||||||
|
import { encryptText } from "../../lib/crypto";
|
||||||
|
import type { Hy2NodeRow, Hy2UserRow } from "../../lib/store";
|
||||||
|
|
||||||
|
const proxy = {
|
||||||
|
name: 'HK "one": #香港',
|
||||||
|
type: "anytls",
|
||||||
|
server: "hk.example.com",
|
||||||
|
port: 443,
|
||||||
|
password: 'test:p"ass#word',
|
||||||
|
udp: true,
|
||||||
|
alpn: ["h2", "http/1.1"],
|
||||||
|
};
|
||||||
|
for (const [name, source] of Object.entries({
|
||||||
|
object: stringify(proxy),
|
||||||
|
sequence: stringify([proxy]),
|
||||||
|
wrapper: stringify({ proxies: [proxy] }),
|
||||||
|
flow: JSON.stringify(proxy),
|
||||||
|
})) {
|
||||||
|
test(`accepts ${name} and preserves protocol fields and quoted names on output`, () => {
|
||||||
|
assert.deepEqual(parseCustomProxyYaml(source), proxy);
|
||||||
|
assert.equal(extractProxyNameFromYaml(source), proxy.name);
|
||||||
|
assert.deepEqual(parse(renderCustomProxyYaml(source)), [proxy]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const value of [
|
||||||
|
"",
|
||||||
|
"[]",
|
||||||
|
"[a, b]",
|
||||||
|
"- name: [bad",
|
||||||
|
"name: X\nname: Y\ntype: anytls",
|
||||||
|
"name: 123\ntype: anytls",
|
||||||
|
"name: X",
|
||||||
|
"name: X\ntype: anytls\npassword: *missing",
|
||||||
|
"proxies:\n - name: X\n type: anytls\n - name: Y\n type: anytls",
|
||||||
|
]) {
|
||||||
|
test(`rejects malformed or multiple proxies: ${JSON.stringify(value).slice(0, 40)}`, () =>
|
||||||
|
assert.throws(() => parseCustomProxyYaml(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mixed subscriptions preserve order, names, group references, credentials and disabled filtering", () => {
|
||||||
|
const custom = {
|
||||||
|
id: 2,
|
||||||
|
slug: "custom",
|
||||||
|
node_type: "custom",
|
||||||
|
name: "Display name",
|
||||||
|
client_name: "Old fallback",
|
||||||
|
custom_config_yaml: stringify({ proxies: [proxy] }),
|
||||||
|
enabled: 1,
|
||||||
|
} as Hy2NodeRow;
|
||||||
|
const hy2 = {
|
||||||
|
id: 1,
|
||||||
|
slug: "hy2",
|
||||||
|
node_type: "hy2",
|
||||||
|
name: "Tokyo",
|
||||||
|
client_name: 'Tokyo "HY2"',
|
||||||
|
server_host: "jp.example.com",
|
||||||
|
server_port: 443,
|
||||||
|
enabled: 1,
|
||||||
|
custom_config_yaml: "",
|
||||||
|
bandwidth_up_mbps: 30,
|
||||||
|
bandwidth_down_mbps: 30,
|
||||||
|
} as Hy2NodeRow;
|
||||||
|
const user = {
|
||||||
|
username: "member",
|
||||||
|
auth_secret_encrypted: encryptText('test:abc"def'),
|
||||||
|
} as Hy2UserRow;
|
||||||
|
const settings = {
|
||||||
|
template: "mode: rule",
|
||||||
|
profileName: 'Profile: "1"',
|
||||||
|
extraProxyGroups: "",
|
||||||
|
mainProxyGroupMemberSlugs: ["custom", "hy2"],
|
||||||
|
proxyGroups: [
|
||||||
|
{
|
||||||
|
id: "work",
|
||||||
|
name: 'Work: "1"',
|
||||||
|
type: "select" as const,
|
||||||
|
memberSlugs: ["custom", "disabled", "hy2"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = parse(
|
||||||
|
buildSubscriptionConfig({
|
||||||
|
user,
|
||||||
|
nodes: [hy2, custom, { ...custom, slug: "disabled", enabled: 0 }],
|
||||||
|
settings,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.proxies[0], proxy);
|
||||||
|
assert.equal(result.proxies[1].password, 'member:test:abc"def');
|
||||||
|
assert.equal(result.proxies[1].name, hy2.client_name);
|
||||||
|
assert.deepEqual(
|
||||||
|
result["proxy-groups"].map((group: { proxies: string[] }) => group.proxies),
|
||||||
|
[
|
||||||
|
[proxy.name, hy2.client_name],
|
||||||
|
[proxy.name, hy2.client_name],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(result["proxy-groups"][0].name, settings.profileName);
|
||||||
|
assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name);
|
||||||
|
assert.equal(result.proxies.length, 2);
|
||||||
|
// Custom-only subscriptions do not require HY2 credentials.
|
||||||
|
assert.deepEqual(
|
||||||
|
parse(
|
||||||
|
buildSubscriptionConfig({
|
||||||
|
user: { ...user, auth_secret_encrypted: null },
|
||||||
|
nodes: [custom],
|
||||||
|
settings,
|
||||||
|
}),
|
||||||
|
).proxies,
|
||||||
|
[proxy],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { migrateDatabase } from "../../lib/db-migrations";
|
||||||
|
|
||||||
|
const remoteSchema = readFileSync(
|
||||||
|
"tests/fixtures/remote-v4-schema.sql",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
function remoteDb() {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
db.exec(remoteSchema);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
function snapshot(db: Database.Database) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
||||||
|
)
|
||||||
|
.all() as { name: string }[]
|
||||||
|
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function seed(db: Database.Database) {
|
||||||
|
db.exec(`INSERT INTO hy2_users (id,auth_id,username,password_hash,notes,created_at,updated_at,subscription_token,auth_secret_encrypted,traffic_limit_bytes,used_tx_bytes,used_rx_bytes)
|
||||||
|
VALUES (42,'original-auth','member','test-hash','keep notes','2026-01-01','2026-01-01','test-sub-token','test-encrypted-value',4096,128,256);
|
||||||
|
INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
|
||||||
|
VALUES (17,'existing-hy2','Existing HY2','test-auth-token','http://stats.example:9999','test-stats-secret','2026-01-01','2026-01-01','existing.example');
|
||||||
|
INSERT INTO traffic_ledgers (node_id,user_id,auth_id,tx_bytes,rx_bytes,window_started_at,window_ended_at,created_at) VALUES (17,42,'original-auth',128,256,'2026-01-01','2026-01-01','2026-01-01');
|
||||||
|
INSERT INTO user_sessions (id,user_id,session_hash,expires_at,created_at,last_seen_at) VALUES ('session',42,'test-session-hash','2030-01-01','2026-01-01','2026-01-01');
|
||||||
|
INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["legacy-custom","existing-hy2"]','2026-01-01');
|
||||||
|
INSERT INTO app_settings VALUES ('proxy_groups_json','[{"id":"group-id","name":"Work","type":"select","memberSlugs":["legacy-custom"]}]','2026-01-01');`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fresh database exactly matches the inspected remote v4 column definitions", () => {
|
||||||
|
const fresh = new Database(":memory:");
|
||||||
|
const remote = remoteDb();
|
||||||
|
migrateDatabase(fresh);
|
||||||
|
assert.equal(fresh.pragma("user_version", { simple: true }), 4);
|
||||||
|
for (const { name } of remote
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
.all() as { name: string }[]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
fresh.pragma(`table_info(${name})`),
|
||||||
|
remote.pragma(`table_info(${name})`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fresh.close();
|
||||||
|
remote.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote v4: repeated migration preserves every row, ID, secret, quota, session and group", () => {
|
||||||
|
const db = remoteDb();
|
||||||
|
seed(db);
|
||||||
|
const source = `# legacy source stays byte-for-byte\n- name: "AnyTLS #1"\n type: anytls\n password: "test: password"\n server: hk.example.com\n port: 443\n`;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name) VALUES (18,'legacy-custom','Legacy','test-token','','test-secret','2026-01-01','2026-01-01','custom',?,'Old fallback')`,
|
||||||
|
).run(source);
|
||||||
|
const before = snapshot(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
assert.deepEqual(snapshot(db), before);
|
||||||
|
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const existing of [
|
||||||
|
[],
|
||||||
|
["node_type"],
|
||||||
|
["custom_config_yaml"],
|
||||||
|
["node_type", "custom_config_yaml"],
|
||||||
|
]) {
|
||||||
|
test(`v3 upgrade with existing columns [${existing.join(", ")}] preserves data`, () => {
|
||||||
|
const db = remoteDb();
|
||||||
|
seed(db);
|
||||||
|
for (const field of ["node_type", "custom_config_yaml"])
|
||||||
|
if (!existing.includes(field))
|
||||||
|
db.exec(`ALTER TABLE hy2_nodes DROP COLUMN ${field}`);
|
||||||
|
db.pragma("user_version = 3");
|
||||||
|
const before = snapshot(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
const after = snapshot(db);
|
||||||
|
// New fields have compatible defaults; every pre-existing field is unchanged.
|
||||||
|
for (const row of after.hy2_nodes as Record<string, unknown>[]) {
|
||||||
|
assert.equal(row.node_type, "hy2");
|
||||||
|
assert.equal(row.custom_config_yaml, "");
|
||||||
|
for (const field of ["node_type", "custom_config_yaml"])
|
||||||
|
if (!existing.includes(field)) delete row[field];
|
||||||
|
}
|
||||||
|
assert.deepEqual(after, before);
|
||||||
|
assert.equal(db.pragma("user_version", { simple: true }), 4);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("repairs interrupted v4 without duplicate-column errors", () => {
|
||||||
|
const db = remoteDb();
|
||||||
|
seed(db);
|
||||||
|
db.exec("ALTER TABLE hy2_nodes DROP COLUMN custom_config_yaml");
|
||||||
|
migrateDatabase(db);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
db.prepare("SELECT custom_config_yaml FROM hy2_nodes").get() as {
|
||||||
|
custom_config_yaml: string;
|
||||||
|
}
|
||||||
|
).custom_config_yaml,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed migrations roll back all statements and future schemas are not downgraded", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
db.exec("CREATE TABLE hy2_users(id INTEGER)");
|
||||||
|
assert.throws(() => migrateDatabase(db));
|
||||||
|
assert.equal(db.pragma("user_version", { simple: true }), 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
|
||||||
|
[{ name: "hy2_users" }],
|
||||||
|
);
|
||||||
|
db.pragma("user_version = 5");
|
||||||
|
assert.throws(() => migrateDatabase(db), /版本/);
|
||||||
|
assert.equal(db.pragma("user_version", { simple: true }), 5);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
// Start with the real deployed schema rather than the new migration output.
|
||||||
|
test("existing v4 boots and custom nodes stay outside HY2 polling/auth while retaining credentials", async () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-custom-store-"));
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "remote-v4.db");
|
||||||
|
process.env.ADMIN_USERNAME = "test-admin";
|
||||||
|
process.env.ADMIN_PASSWORD = "test-admin-local-only";
|
||||||
|
process.env.POLLER_ENABLED = "false";
|
||||||
|
const legacy = new Database(process.env.DATABASE_PATH);
|
||||||
|
legacy.exec(readFileSync("tests/fixtures/remote-v4-schema.sql", "utf8"));
|
||||||
|
legacy.exec(`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
|
||||||
|
VALUES (91,'existing-hy2','Existing HY2','test-auth','http://stats.example:9999','test-stats','2026-01-01','2026-01-01','hy2.example');`);
|
||||||
|
const yaml =
|
||||||
|
'# original comment\n- name: "Remote Custom"\n type: anytls\n password: "test-pass"\n server: custom.example\n port: 443\n';
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name)
|
||||||
|
VALUES (92,'existing-custom','Display','test-original-auth','','test-original-stats','2026-01-01','2026-01-01','custom',?,'Old alias')`,
|
||||||
|
)
|
||||||
|
.run(yaml);
|
||||||
|
legacy.exec(
|
||||||
|
`INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["existing-custom","existing-hy2"]','2026-01-01')`,
|
||||||
|
);
|
||||||
|
const before = legacy.prepare("SELECT * FROM hy2_nodes ORDER BY id").all();
|
||||||
|
legacy.close();
|
||||||
|
const { ensureDb } = await import("../../lib/db");
|
||||||
|
const store = await import("../../lib/store");
|
||||||
|
const { verifyNodeToken } = await import("../../lib/hy2");
|
||||||
|
const db = await ensureDb();
|
||||||
|
try {
|
||||||
|
assert.deepEqual(
|
||||||
|
db.prepare("SELECT * FROM hy2_nodes ORDER BY id").all(),
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await store.getSubscriptionSettings()).mainProxyGroupMemberSlugs,
|
||||||
|
["existing-custom", "existing-hy2"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await store.listEnabledNodesForPolling()).map((node) => node.id),
|
||||||
|
[91],
|
||||||
|
);
|
||||||
|
assert.equal((await store.listSubscriptionNodes()).length, 2);
|
||||||
|
assert.equal(
|
||||||
|
(await verifyNodeToken("existing-custom", "test-original-auth")).reason,
|
||||||
|
"node_not_hy2",
|
||||||
|
);
|
||||||
|
assert.equal((await verifyNodeToken("existing-hy2", "test-auth")).ok, true);
|
||||||
|
const custom = (await store.getNodeBySlug("existing-custom"))!;
|
||||||
|
assert.equal(store.getNodeProxyName(custom), "Remote Custom");
|
||||||
|
const input = {
|
||||||
|
nodeType: "custom" as const,
|
||||||
|
slug: "existing-custom",
|
||||||
|
name: "Display",
|
||||||
|
authToken: "",
|
||||||
|
hy2Listen: "",
|
||||||
|
serverHost: "",
|
||||||
|
serverPort: 443,
|
||||||
|
clientName: "",
|
||||||
|
bandwidthUpMbps: 30,
|
||||||
|
bandwidthDownMbps: 30,
|
||||||
|
skipCertVerify: false,
|
||||||
|
sni: null,
|
||||||
|
customConfigYaml: yaml.replace("Remote Custom", "Updated Custom"),
|
||||||
|
trafficStatsUrl: "",
|
||||||
|
trafficStatsListen: ":9999",
|
||||||
|
trafficStatsSecret: "",
|
||||||
|
enabled: true,
|
||||||
|
pollIntervalSeconds: 15,
|
||||||
|
};
|
||||||
|
await store.updateNode({ ...input, id: custom.id });
|
||||||
|
const saved = (await store.getNodeBySlug("existing-custom"))!;
|
||||||
|
assert.equal(saved.id, 92);
|
||||||
|
assert.equal(saved.custom_config_yaml, input.customConfigYaml);
|
||||||
|
assert.equal(saved.auth_token, "test-original-auth");
|
||||||
|
assert.equal(saved.traffic_stats_secret, "test-original-stats");
|
||||||
|
assert.equal(store.getNodeProxyName(saved), "Updated Custom");
|
||||||
|
await assert.rejects(() =>
|
||||||
|
store.updateNode({
|
||||||
|
...input,
|
||||||
|
id: 92,
|
||||||
|
customConfigYaml: "name: [invalid",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(await store.getNodeBySlug("existing-custom"), saved);
|
||||||
|
await store.updateNode({ ...input, id: 92, enabled: false });
|
||||||
|
assert.deepEqual(
|
||||||
|
(await store.listSubscriptionNodes()).map((node) => node.id),
|
||||||
|
[91],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
global.__hy2PanelDb = undefined;
|
||||||
|
global.__hy2PanelDbInit = undefined;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user