From 2e3eb4b8f8d703a0e61ef9922104185379251274 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Tue, 14 Apr 2026 19:12:20 +0800 Subject: [PATCH] feat: improve admin experience and ignore local data --- .gitignore | 1 + README.md | 34 +- app/admin/subscriptions/actions.ts | 27 +- app/admin/subscriptions/page.tsx | 76 +-- .../subscriptions/proxy-group-editor.tsx | 292 +++++++++++ app/globals.css | 492 ++++++++++++++++++ app/logout/route.ts | 7 +- app/me/page.tsx | 169 ++++-- deploy/nginx-hy2-panel.conf | 53 ++ lib/request-origin.ts | 100 +++- 10 files changed, 1132 insertions(+), 119 deletions(-) create mode 100644 app/admin/subscriptions/proxy-group-editor.tsx create mode 100644 deploy/nginx-hy2-panel.conf diff --git a/.gitignore b/.gitignore index 5ef6a52..bc41405 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # production /build +/data/ # misc .DS_Store diff --git a/README.md b/README.md index 803db29..fa45ea0 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,36 @@ sudo systemctl daemon-reload sudo systemctl enable --now hy2-panel ``` -如果你需要对外开放,再在 Nginx/Caddy 前面反代 `127.0.0.1:3000` 即可。 +## Nginx 反代 + +已提供示例站点配置 [deploy/nginx-hy2-panel.conf](/root/hy2-panel/deploy/nginx-hy2-panel.conf)。 + +如果你的公网域名是 `hy2.xn--876a.net`,建议生产环境把 `.env` 中的 `APP_URL` 改成: + +```bash +APP_URL=https://hy2.xn--876a.net +SESSION_COOKIE_SECURE=auto +``` + +示例配置默认反代到 `127.0.0.1:3000`,证书路径为: + +```text +/etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem +/etc/nginx/ssl/hy2.xn--876a.net/privkey.pem +``` + +典型启用流程: + +```bash +sudo mkdir -p /etc/nginx/ssl/hy2.xn--876a.net +sudo cp fullchain.pem /etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem +sudo cp privkey.pem /etc/nginx/ssl/hy2.xn--876a.net/privkey.pem +sudo cp deploy/nginx-hy2-panel.conf /etc/nginx/sites-available/hy2-panel.conf +sudo ln -sf /etc/nginx/sites-available/hy2-panel.conf /etc/nginx/sites-enabled/hy2-panel.conf +sudo nginx -t +sudo systemctl reload nginx +``` + +如果你当前的 hy2 节点仍然直接访问 `http://38.246.237.36:3000/api/hy2/...` 作为鉴权地址,可以继续保留这一入口。上面的 Nginx 只接管 `80/443` 上的域名访问,不会替换或中断现有的 `:3000` 直连。 + +面板内部生成链接时会优先使用反代传入的 `X-Forwarded-Host` / `X-Forwarded-Proto`,如果是直接访问 `http://38.246.237.36:3000`,则会自动退回到当前请求的 `Host`。 diff --git a/app/admin/subscriptions/actions.ts b/app/admin/subscriptions/actions.ts index 3dc840b..f2fc0cc 100644 --- a/app/admin/subscriptions/actions.ts +++ b/app/admin/subscriptions/actions.ts @@ -28,22 +28,35 @@ export async function saveProxyGroupAction(formData: FormData) { const settings = await getSubscriptionSettings(); const id = String(formData.get("id") ?? "").trim() || createStableId(); const name = String(formData.get("name") ?? "").trim(); - const memberSlugs = formData - .getAll("member_slugs") - .map((value) => String(value)) - .filter(Boolean); + const seen = new Set(); + const memberSlugs = formData.getAll("member_slugs").flatMap((value) => { + const slug = String(value).trim(); + if (!slug || seen.has(slug)) { + return []; + } + + seen.add(slug); + return [slug]; + }); if (!name) { return; } - const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id); - proxyGroups.push({ + const nextGroup = { id, name, type: "select", memberSlugs, - }); + } as const; + const existingIndex = settings.proxyGroups.findIndex((group) => group.id === id); + const proxyGroups = [...settings.proxyGroups]; + + if (existingIndex === -1) { + proxyGroups.push(nextGroup); + } else { + proxyGroups[existingIndex] = nextGroup; + } await saveProxyGroups(proxyGroups); revalidatePath("/admin/subscriptions"); diff --git a/app/admin/subscriptions/page.tsx b/app/admin/subscriptions/page.tsx index 204b539..ff703f6 100644 --- a/app/admin/subscriptions/page.tsx +++ b/app/admin/subscriptions/page.tsx @@ -6,11 +6,8 @@ import { listSubscriptionNodes, } from "@/lib/store"; -import { - deleteProxyGroupAction, - saveProxyGroupAction, - saveSubscriptionSettingsAction, -} from "./actions"; +import { saveSubscriptionSettingsAction } from "./actions"; +import { ProxyGroupEditor } from "./proxy-group-editor"; export default async function AdminSubscriptionsPage() { const [settings, adminUser, nodes, origin] = await Promise.all([ @@ -19,6 +16,14 @@ export default async function AdminSubscriptionsPage() { listSubscriptionNodes(), getRequestOrigin(), ]); + const proxyGroupNodes = nodes.map((node) => ({ + slug: node.slug, + label: node.client_name || node.name, + meta: + node.client_name && node.client_name !== node.name + ? `${node.name} / ${node.slug}` + : node.slug, + })); return (
@@ -62,65 +67,18 @@ export default async function AdminSubscriptionsPage() {
-
-

Proxy Groups

+
+
+

Proxy Groups

+

已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。

+
{settings.proxyGroups.map((group) => ( -
-
- - -
- {nodes.map((node) => ( - - ))} -
-
- -
-
-
- - -
-
+ ))} -
-
- -
- {nodes.map((node) => ( - - ))} -
- -
-
+
diff --git a/app/admin/subscriptions/proxy-group-editor.tsx b/app/admin/subscriptions/proxy-group-editor.tsx new file mode 100644 index 0000000..8da8082 --- /dev/null +++ b/app/admin/subscriptions/proxy-group-editor.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useDeferredValue, useState } from "react"; +import { useFormStatus } from "react-dom"; + +import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions"; + +type ProxyGroupNodeOption = { + slug: string; + label: string; + meta: string; +}; + +type ProxyGroupEditorProps = { + group?: { + id: string; + name: string; + memberSlugs: string[]; + }; + nodes: ProxyGroupNodeOption[]; +}; + +function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) { + const availableSlugs = new Set(nodes.map((node) => node.slug)); + const seen = new Set(); + + return initialSlugs.filter((slug) => { + if (!availableSlugs.has(slug) || seen.has(slug)) { + return false; + } + + seen.add(slug); + return true; + }); +} + +function SaveButton({ isEditing }: { isEditing: boolean }) { + const { pending } = useFormStatus(); + + return ( + + ); +} + +function DeleteButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) { + const isEditing = Boolean(group); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const [selectedSlugs, setSelectedSlugs] = useState(() => + normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes), + ); + const selectedSlugSet = new Set(selectedSlugs); + const nodesBySlug = new Map(nodes.map((node) => [node.slug, node])); + const selectedNodes = selectedSlugs + .map((slug) => nodesBySlug.get(slug)) + .filter((node): node is ProxyGroupNodeOption => Boolean(node)); + const normalizedQuery = deferredQuery.trim().toLowerCase(); + const remainingCount = nodes.length - selectedNodes.length; + const availableNodes = nodes.filter((node) => { + if (selectedSlugSet.has(node.slug)) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + return [node.label, node.meta, node.slug].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); + }); + + function addNode(slug: string) { + setSelectedSlugs((current) => { + if (current.includes(slug)) { + return current; + } + + return [...current, slug]; + }); + } + + function removeNode(slug: string) { + setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug)); + } + + function moveNode(slug: string, direction: -1 | 1) { + setSelectedSlugs((current) => { + const index = current.indexOf(slug); + const nextIndex = index + direction; + + if (index === -1 || nextIndex < 0 || nextIndex >= current.length) { + return current; + } + + const next = [...current]; + [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; + return next; + }); + } + + function addAllVisibleNodes() { + setSelectedSlugs((current) => { + const next = [...current]; + const seen = new Set(current); + + for (const node of availableNodes) { + if (seen.has(node.slug)) { + continue; + } + + next.push(node.slug); + seen.add(node.slug); + } + + return next; + }); + } + + function clearSelection() { + setSelectedSlugs([]); + } + + return ( +
+
+ {group ? : null} +
+
+

{isEditing ? group?.name : "新建分组"}

+

右侧顺序就是最终写入 Clash 的节点顺序。

+
+
+ 已选 {selectedNodes.length} + 未加入 {remainingCount} +
+
+ + + +
+
+
+
+ 候选节点 + 筛选后点击添加,新节点会追加到分组末尾。 +
+
+ + +
+
+ + {availableNodes.length > 0 ? ( +
+ {availableNodes.map((node) => ( + + ))} +
+ ) : ( +

+ {remainingCount === 0 + ? "所有可用节点都已加入当前分组。" + : "没有匹配当前筛选条件的节点。"} +

+ )} +
+ +
+
+
+ 已选顺序 + 上移和下移会直接改变最终输出顺序。 +
+ +
+ + {selectedNodes.length > 0 ? ( +
    + {selectedNodes.map((node, index) => ( +
  1. +
    + {String(index + 1).padStart(2, "0")} +
    + {node.label} + {node.meta} +
    +
    +
    + + + +
    + +
  2. + ))} +
+ ) : ( +

+ 还没有选择节点。先从左侧添加,再在这里调整最终顺序。 +

+ )} +
+
+ +
+ 保存后会立即更新订阅模板里的 proxy-groups。 + +
+
+ + {group ? ( +
+ + + + ) : null} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index a3b8f3d..e4edbc5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -115,6 +115,13 @@ button:hover { transform: translateY(-1px); } +button:disabled { + cursor: not-allowed; + opacity: 0.58; + transform: none; + box-shadow: none; +} + .login-shell { min-height: 100vh; display: grid; @@ -292,6 +299,279 @@ button:hover { font-size: 0.92rem; } +.me-shell { + position: relative; + min-height: 100vh; + overflow: hidden; + padding: 2rem; + display: grid; + place-items: center; + background: + radial-gradient(circle at 16% 18%, rgba(212, 112, 58, 0.3), transparent 20rem), + radial-gradient(circle at 82% 16%, rgba(41, 92, 114, 0.18), transparent 24rem), + radial-gradient(circle at 50% 100%, rgba(24, 34, 44, 0.16), transparent 24rem), + linear-gradient(145deg, #f7ebdc 0%, #efdfca 42%, #e4d5c5 100%); +} + +.me-shell::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.16) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.16) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 90%); +} + +.me-backdrop { + position: absolute; + inset: 0; + pointer-events: none; +} + +.me-glow { + position: absolute; + border-radius: 999px; + filter: blur(20px); + opacity: 0.85; + animation: login-float 16s ease-in-out infinite; +} + +.me-glow--warm { + top: 9%; + left: max(2rem, 7vw); + width: 17rem; + height: 17rem; + background: radial-gradient(circle, rgba(210, 104, 53, 0.28), rgba(210, 104, 53, 0.02)); +} + +.me-glow--cool { + right: max(2rem, 7vw); + bottom: 10%; + width: 20rem; + height: 20rem; + background: radial-gradient(circle, rgba(47, 97, 117, 0.22), rgba(47, 97, 117, 0.03)); + animation-duration: 19s; + animation-delay: -5s; +} + +.me-glow--light { + top: 26%; + right: 18%; + width: 8rem; + height: 8rem; + background: radial-gradient(circle, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.04)); + animation-duration: 12s; + animation-delay: -2s; +} + +.me-stage { + position: relative; + z-index: 1; + width: min(72rem, 100%); + display: grid; + gap: 1.25rem; +} + +.me-grid, +.me-side-stack, +.me-tip-grid, +.me-metrics-grid, +.me-hero-copy, +.me-identity-card { + display: grid; +} + +.me-grid { + grid-template-columns: minmax(0, 1.24fr) minmax(18rem, 0.86fr); + gap: 1.25rem; +} + +.me-side-stack { + gap: 1.25rem; +} + +.me-panel { + position: relative; + overflow: hidden; + border-radius: 32px; + border: 1px solid rgba(24, 34, 44, 0.1); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 34%), + rgba(255, 251, 245, 0.7); + backdrop-filter: blur(18px); + box-shadow: 0 28px 80px rgba(51, 35, 18, 0.12); + padding: 1.4rem; +} + +.me-panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.34), transparent 38%); +} + +.me-hero { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(18rem, 0.8fr); + gap: 1rem; + align-items: stretch; +} + +.me-hero-copy { + align-content: start; + gap: 0.85rem; +} + +.me-hero-copy h1, +.me-subscription-card h2, +.me-support-card h2 { + margin: 0; + line-height: 1.06; +} + +.me-hero-copy h1 { + font-size: clamp(2.2rem, 5vw, 3.4rem); + letter-spacing: -0.06em; +} + +.me-identity-card { + align-content: space-between; + gap: 0.8rem; + min-height: 100%; + padding: 1.15rem; + border-radius: 26px; + border: 1px solid rgba(24, 34, 44, 0.08); + background: + radial-gradient(circle at top right, rgba(201, 98, 51, 0.12), transparent 12rem), + rgba(255, 255, 255, 0.44); +} + +.me-identity-card strong { + font-size: 1.06rem; +} + +.me-subscription-card { + display: grid; + gap: 1rem; + align-content: start; +} + +.me-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; +} + +.me-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3.2rem; + min-width: 12rem; + text-align: center; +} + +.me-action-button--full { + width: 100%; +} + +.me-link-card { + display: grid; + gap: 0.55rem; + padding: 1rem 1.05rem; + border-radius: 24px; + border: 1px solid rgba(24, 34, 44, 0.08); + background: rgba(255, 255, 255, 0.42); +} + +.me-link-card p { + margin: 0; + line-height: 1.65; +} + +.me-tip-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.me-tip-card { + display: grid; + gap: 0.45rem; + padding: 1rem; + border-radius: 22px; + border: 1px solid rgba(24, 34, 44, 0.08); + background: rgba(255, 255, 255, 0.38); +} + +.me-tip-card strong { + font-size: 1rem; +} + +.me-tip-card p { + margin: 0; + line-height: 1.6; +} + +.me-metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.me-metric-card { + display: grid; + gap: 0.42rem; + min-height: 8.4rem; + padding: 1rem 1.05rem; + border-radius: 24px; + border: 1px solid rgba(24, 34, 44, 0.08); +} + +.me-metric-card span { + color: var(--muted); + font-size: 0.9rem; +} + +.me-metric-card strong { + font-size: 1.4rem; + line-height: 1.15; + letter-spacing: -0.04em; +} + +.me-metric-card--paper { + background: linear-gradient(180deg, rgba(255, 252, 247, 0.95), rgba(255, 250, 242, 0.72)); +} + +.me-metric-card--warm { + background: linear-gradient(180deg, rgba(247, 225, 203, 0.92), rgba(255, 250, 242, 0.72)); +} + +.me-metric-card--ink { + background: linear-gradient(180deg, rgba(223, 231, 236, 0.92), rgba(255, 250, 242, 0.72)); +} + +.me-metric-card--sage { + background: linear-gradient(180deg, rgba(224, 236, 228, 0.92), rgba(255, 250, 242, 0.72)); +} + +.me-support-card { + display: grid; + gap: 1rem; + align-content: start; +} + +.me-note-list { + margin: 0; + padding-left: 1.15rem; + display: grid; + gap: 0.75rem; + color: var(--muted); + line-height: 1.6; +} + .wide-card { width: min(56rem, 100%); } @@ -780,6 +1060,171 @@ button:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.proxy-group-card { + display: grid; + gap: 1rem; +} + +.proxy-group-title, +.proxy-group-caption { + margin: 0; +} + +.proxy-group-title { + font-size: 1.05rem; +} + +.proxy-group-builder { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); +} + +.proxy-group-panel { + display: grid; + gap: 0.9rem; + align-content: start; + min-height: 20rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.32)); +} + +.proxy-group-panel--selected { + background: linear-gradient(180deg, rgba(255, 246, 238, 0.68), rgba(255, 255, 255, 0.34)); +} + +.proxy-group-panel__header { + display: grid; + gap: 0.75rem; +} + +.proxy-group-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.proxy-group-search { + flex: 1 1 16rem; +} + +.proxy-group-search input { + min-width: 0; +} + +.proxy-toolbar-button, +.proxy-sort-button { + padding: 0.72rem 0.88rem; + border-radius: 14px; +} + +.proxy-node-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.proxy-node-chip { + display: flex; + align-items: start; + justify-content: space-between; + gap: 0.85rem; + width: 100%; + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: 18px; + text-align: left; + background: rgba(255, 255, 255, 0.5); +} + +.proxy-node-chip:hover { + background: rgba(255, 255, 255, 0.78); + box-shadow: var(--shadow-soft); +} + +.proxy-node-chip__copy { + display: grid; + gap: 0.28rem; + min-width: 0; +} + +.proxy-node-chip__copy strong { + font-size: 0.95rem; +} + +.proxy-node-chip__copy span { + color: var(--muted); + font-size: 0.82rem; + word-break: break-all; +} + +.proxy-node-chip__action { + flex: none; + color: var(--accent-deep); + font-size: 0.82rem; + font-weight: 700; +} + +.proxy-sort-list { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.proxy-sort-item { + display: grid; + gap: 0.75rem; + padding: 0.95rem 1rem; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); +} + +.proxy-sort-item__meta { + display: flex; + align-items: start; + gap: 0.85rem; + min-width: 0; +} + +.proxy-sort-item__index { + display: inline-grid; + place-items: center; + width: 2.2rem; + height: 2.2rem; + flex: none; + border-radius: 999px; + background: rgba(201, 98, 51, 0.12); + color: var(--accent-deep); + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: 700; +} + +.proxy-sort-item__sub { + font-size: 0.82rem; + word-break: break-all; +} + +.proxy-sort-item__actions { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; +} + +.proxy-group-empty { + margin: 0; + padding: 1rem; + border: 1px dashed var(--border-strong); + border-radius: 18px; + background: rgba(255, 255, 255, 0.36); +} + .list-row--card { padding: 0.9rem 1rem; border: 1px solid var(--border); @@ -822,12 +1267,18 @@ button:hover { @media (max-width: 960px) { .admin-shell, .login-layout, + .me-grid, + .me-hero, + .me-tip-grid, + .me-metrics-grid, .hero-panel, .hero-panel--compact, .hero-aside, .metrics-grid, .panel-grid, .form-grid, + .proxy-group-builder, + .proxy-node-grid, .checkbox-grid, .summary-grid, .feature-grid { @@ -863,6 +1314,10 @@ button:hover { .login-points { grid-template-columns: 1fr; } + + .proxy-group-toolbar { + align-items: stretch; + } } @media (max-width: 640px) { @@ -870,6 +1325,33 @@ button:hover { padding: 1.15rem; } + .me-shell { + padding: 1rem; + } + + .me-panel { + padding: 1.05rem; + border-radius: 24px; + } + + .me-action-row { + flex-direction: column; + } + + .me-action-button { + width: 100%; + } + + .me-glow--warm { + width: 13rem; + height: 13rem; + } + + .me-glow--cool { + width: 15rem; + height: 15rem; + } + .login-card--brand { padding: 1.4rem; border-radius: 24px; @@ -884,4 +1366,14 @@ button:hover { width: 15rem; height: 15rem; } + + .proxy-sort-item__actions { + display: grid; + grid-template-columns: 1fr; + } + + .proxy-toolbar-button, + .proxy-sort-button { + width: 100%; + } } diff --git a/app/logout/route.ts b/app/logout/route.ts index 10c8101..d5abe88 100644 --- a/app/logout/route.ts +++ b/app/logout/route.ts @@ -2,7 +2,10 @@ import { NextResponse } from "next/server"; import { destroyAdminSession, destroyUserSession } from "@/lib/session"; -export async function POST(request: Request) { +export async function POST() { await Promise.all([destroyAdminSession(), destroyUserSession()]); - return NextResponse.redirect(new URL("/login", request.url)); + + const response = new NextResponse(null, { status: 303 }); + response.headers.set("Location", "/login"); + return response; } diff --git a/app/me/page.tsx b/app/me/page.tsx index 91eb6f7..9a9301f 100644 --- a/app/me/page.tsx +++ b/app/me/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getRequestOrigin } from "@/lib/request-origin"; @@ -5,6 +6,10 @@ import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscripti import { requireUserSession } from "@/lib/session"; import { formatBytes, getUserById } from "@/lib/store"; +export const metadata: Metadata = { + title: "我的订阅", +}; + export default async function MePage() { const session = await requireUserSession(); const [user, origin] = await Promise.all([ @@ -16,59 +21,145 @@ export default async function MePage() { redirect("/login"); } + const usedBytes = user.used_tx_bytes + user.used_rx_bytes; + const subscriptionUrl = user.subscription_token + ? buildSubscriptionUrl(user.subscription_token, origin) + : null; + const clashImportUrl = subscriptionUrl + ? `clash://install-config?url=${encodeURIComponent(subscriptionUrl)}` + : null; + return ( -
-
-
-
- SELF SERVICE +
+ +
+
+
+ FEIEPROXY SELF SERVICE

{user.username}

- 已用 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)},{describeRemainingTraffic(user)} + 鹅梯 FeieProxy 用户面板。这里可以查看配额状态、获取订阅链接,并通过 + Clash URL Scheme 一键导入当前配置。

- - {user.enabled ? "已启用" : "已禁用"} + + {user.enabled ? "账号已启用" : "账号已禁用"} {user.expires_at ?? "长期有效"} + + {describeRemainingTraffic(user)} +
-
-
- auth_id - {user.auth_id} +
+ ACCOUNT SNAPSHOT + {user.auth_id} +

+ 订阅名称:{user.subscription_label || user.username} +

+
+
+ +
+
+
+
+ SUBSCRIPTION +

一键导入配置

+
+ Clash / Mihomo
-
- 订阅链接 - - {user.subscription_token - ? buildSubscriptionUrl(user.subscription_token, origin) - : "暂不可用"} - +

+ 推荐直接通过按钮唤起支持 `clash://` 的客户端导入。若浏览器拦截了自定义协议,允许一次即可。 +

+
+ {clashImportUrl ? ( + + 一键导入 Clash + + ) : ( + 订阅暂不可用 + )} + {subscriptionUrl ? ( + + 打开订阅链接 + + ) : null}
+
+ SUBSCRIPTION URL +

{subscriptionUrl ?? "暂不可用"}

+
+
+
+ TIP 01 + 客户端未唤起时 +

确认已安装支持 `clash://` 的客户端,并允许浏览器打开外部应用。

+
+
+ TIP 02 + 手动导入也可用 +

复制上方订阅链接,在 Clash / Mihomo 客户端中粘贴导入即可。

+
+
+
+ +
+
+
+

流量概览

+
+
+
+ 总用量 + {formatBytes(usedBytes)} +
+
+ 剩余额度 + {describeRemainingTraffic(user)} +
+
+ 上传 + {formatBytes(user.used_tx_bytes)} +
+
+ 下载 + {formatBytes(user.used_rx_bytes)} +
+
+
+ +
+ ACCESS +

使用说明

+
    +
  • 域名入口适合网页登录与获取订阅。
  • +
  • 现有节点鉴权仍可继续使用原来的 `IP:3000` 直连地址。
  • +
  • 如果订阅内容更新,重新点击导入按钮即可刷新客户端配置。
  • +
+
+ +
+
-
-
-
- 上传 - {formatBytes(user.used_tx_bytes)} -
-
- 下载 - {formatBytes(user.used_rx_bytes)} -
-
- 剩余额度 - {describeRemainingTraffic(user)} -
-
-
- -
+
); diff --git a/deploy/nginx-hy2-panel.conf b/deploy/nginx-hy2-panel.conf new file mode 100644 index 0000000..0ec6ccb --- /dev/null +++ b/deploy/nginx-hy2-panel.conf @@ -0,0 +1,53 @@ +# If your certificate files are stored elsewhere, update these two paths first. +upstream hy2_panel_upstream { + server 127.0.0.1:3000; + keepalive 32; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name hy2.xn--876a.net; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name hy2.xn--876a.net; + + ssl_certificate /etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/hy2.xn--876a.net/privkey.pem; + ssl_session_cache shared:hy2_panel_ssl:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=31536000" always; + + client_max_body_size 10m; + + location / { + proxy_http_version 1.1; + proxy_pass http://hy2_panel_upstream; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_buffering off; + } +} diff --git a/lib/request-origin.ts b/lib/request-origin.ts index 7ab4746..42391df 100644 --- a/lib/request-origin.ts +++ b/lib/request-origin.ts @@ -2,23 +2,101 @@ import { headers } from "next/headers"; import { getEnv } from "@/lib/env"; -export async function getRequestOrigin() { - const headerList = await headers(); - const forwardedProto = headerList.get("x-forwarded-proto"); - const forwardedHost = headerList.get("x-forwarded-host"); - const host = forwardedHost || headerList.get("host"); +function getFirstHeaderValue(value: string | null) { + return value?.split(",")[0]?.trim() || null; +} - if (!host) { - return getEnv().APP_URL; +function sanitizeProto(value: string | null) { + const candidate = getFirstHeaderValue(value)?.toLowerCase(); + if (candidate === "http" || candidate === "https") { + return candidate; } - const proto = - forwardedProto || (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "http"); + return null; +} - return `${proto}://${host}`; +function sanitizeHost(value: string | null) { + const candidate = getFirstHeaderValue(value)?.replace(/^"|"$/g, ""); + if (!candidate) { + return null; + } + + if (candidate.includes("/") || /\s/.test(candidate)) { + return null; + } + + return candidate; +} + +function parseForwardedHeader(value: string | null) { + const firstEntry = getFirstHeaderValue(value); + if (!firstEntry) { + return { + host: null, + proto: null, + }; + } + + let host: string | null = null; + let proto: "http" | "https" | null = null; + + for (const segment of firstEntry.split(";")) { + const [rawKey, ...rest] = segment.split("="); + const key = rawKey?.trim().toLowerCase(); + const rawValue = rest.join("=").trim(); + + if (!key || !rawValue) { + continue; + } + + if (key === "host") { + host = sanitizeHost(rawValue) || host; + } else if (key === "proto") { + proto = sanitizeProto(rawValue) || proto; + } + } + + return { host, proto }; +} + +function buildOriginFromParts( + host: string | null, + proto: "http" | "https" | null, + fallback: string, +) { + if (!host) { + return fallback; + } + + return `${proto || "http"}://${host}`; +} + +export async function getRequestOrigin() { + const headerList = await headers(); + const envOrigin = getEnv().APP_URL; + const forwarded = parseForwardedHeader(headerList.get("forwarded")); + const host = + sanitizeHost(headerList.get("x-forwarded-host")) || + forwarded.host || + sanitizeHost(headerList.get("host")); + const proto = + sanitizeProto(headerList.get("x-forwarded-proto")) || forwarded.proto; + + return buildOriginFromParts(host, proto, envOrigin); } export function getOriginFromRequest(request: Request) { const url = new URL(request.url); - return `${url.protocol}//${url.host}`; + const forwarded = parseForwardedHeader(request.headers.get("forwarded")); + const host = + sanitizeHost(request.headers.get("x-forwarded-host")) || + forwarded.host || + sanitizeHost(request.headers.get("host")) || + url.host; + const proto = + sanitizeProto(request.headers.get("x-forwarded-proto")) || + forwarded.proto || + sanitizeProto(url.protocol.replace(/:$/, "")); + + return buildOriginFromParts(host, proto, getEnv().APP_URL); }