feat: improve admin experience and ignore local data
This commit is contained in:
parent
6378ab4994
commit
2e3eb4b8f8
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,6 +19,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/data/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
34
README.md
34
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`。
|
||||
|
||||
@ -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<string>();
|
||||
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");
|
||||
|
||||
@ -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 (
|
||||
<main className="page-stack">
|
||||
@ -62,65 +67,18 @@ export default async function AdminSubscriptionsPage() {
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Proxy Groups</h3>
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h3>Proxy Groups</h3>
|
||||
<p className="muted">已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack-lg">
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<article key={group.id} className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<label className="field">
|
||||
<span>组名</span>
|
||||
<input name="name" defaultValue={group.name} required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`${group.id}-${node.slug}`} className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="member_slugs"
|
||||
value={node.slug}
|
||||
defaultChecked={group.memberSlugs.includes(node.slug)}
|
||||
/>
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button className="ghost-button" type="submit">
|
||||
保存分组
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||
))}
|
||||
|
||||
<article className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<label className="field">
|
||||
<span>新建分组名</span>
|
||||
<input name="name" placeholder="ChatGPT" required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`new-${node.slug}`} className="checkbox">
|
||||
<input type="checkbox" name="member_slugs" value={node.slug} />
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary-button" type="submit">
|
||||
新建分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
<ProxyGroupEditor key={`new-${settings.proxyGroups.length}`} nodes={proxyGroupNodes} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
292
app/admin/subscriptions/proxy-group-editor.tsx
Normal file
292
app/admin/subscriptions/proxy-group-editor.tsx
Normal file
@ -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<string>();
|
||||
|
||||
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 (
|
||||
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
|
||||
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button className="danger-button" type="submit" disabled={pending}>
|
||||
{pending ? "删除中..." : "删除分组"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className="instruction-block proxy-group-card">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
{group ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
|
||||
<p className="muted proxy-group-caption">右侧顺序就是最终写入 Clash 的节点顺序。</p>
|
||||
</div>
|
||||
<div className="badge-row badge-row--end">
|
||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
|
||||
<div className="proxy-group-builder">
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
<span className="muted">筛选后点击添加,新节点会追加到分组末尾。</span>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<button
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>已选顺序</strong>
|
||||
<span className="muted">上移和下移会直接改变最终输出顺序。</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedNodes.length > 0 ? (
|
||||
<ol className="proxy-sort-list">
|
||||
{selectedNodes.map((node, index) => (
|
||||
<li key={node.slug} className="proxy-sort-item">
|
||||
<div className="proxy-sort-item__meta">
|
||||
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
|
||||
<div className="stack-tight">
|
||||
<strong>{node.label}</strong>
|
||||
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="proxy-sort-item__actions">
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, 1)}
|
||||
disabled={index === selectedNodes.length - 1}
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
|
||||
<SaveButton isEditing={isEditing} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{group ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
492
app/globals.css
492
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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
169
app/me/page.tsx
169
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 (
|
||||
<main className="login-shell">
|
||||
<section className="login-card wide-card">
|
||||
<div className="hero-panel hero-panel--compact">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">SELF SERVICE</span>
|
||||
<main className="me-shell">
|
||||
<div aria-hidden="true" className="me-backdrop">
|
||||
<span className="me-glow me-glow--warm" />
|
||||
<span className="me-glow me-glow--cool" />
|
||||
<span className="me-glow me-glow--light" />
|
||||
</div>
|
||||
<section className="me-stage">
|
||||
<article className="me-panel me-hero">
|
||||
<div className="me-hero-copy">
|
||||
<span className="eyebrow">FEIEPROXY SELF SERVICE</span>
|
||||
<h1>{user.username}</h1>
|
||||
<p className="page-lede">
|
||||
已用 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)},{describeRemainingTraffic(user)}
|
||||
鹅梯 FeieProxy 用户面板。这里可以查看配额状态、获取订阅链接,并通过
|
||||
Clash URL Scheme 一键导入当前配置。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className={`status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||
{user.enabled ? "已启用" : "已禁用"}
|
||||
<span
|
||||
className={`status-pill ${
|
||||
user.enabled ? "status-pill--ok" : "status-pill--danger"
|
||||
}`}
|
||||
>
|
||||
{user.enabled ? "账号已启用" : "账号已禁用"}
|
||||
</span>
|
||||
<span className="status-pill status-pill--neutral">
|
||||
{user.expires_at ?? "长期有效"}
|
||||
</span>
|
||||
<span className="status-pill status-pill--warm">
|
||||
{describeRemainingTraffic(user)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>auth_id</span>
|
||||
<strong className="mono break-all">{user.auth_id}</strong>
|
||||
<div className="me-identity-card">
|
||||
<span className="feature-kicker">ACCOUNT SNAPSHOT</span>
|
||||
<strong className="mono break-all">{user.auth_id}</strong>
|
||||
<p className="muted">
|
||||
订阅名称:{user.subscription_label || user.username}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section className="me-grid">
|
||||
<article className="me-panel me-subscription-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<span className="feature-kicker">SUBSCRIPTION</span>
|
||||
<h2>一键导入配置</h2>
|
||||
</div>
|
||||
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<span>订阅链接</span>
|
||||
<strong className="mono break-all">
|
||||
{user.subscription_token
|
||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||
: "暂不可用"}
|
||||
</strong>
|
||||
<p className="muted">
|
||||
推荐直接通过按钮唤起支持 `clash://` 的客户端导入。若浏览器拦截了自定义协议,允许一次即可。
|
||||
</p>
|
||||
<div className="me-action-row">
|
||||
{clashImportUrl ? (
|
||||
<a className="primary-button me-action-button" href={clashImportUrl}>
|
||||
一键导入 Clash
|
||||
</a>
|
||||
) : (
|
||||
<span className="status-pill status-pill--off">订阅暂不可用</span>
|
||||
)}
|
||||
{subscriptionUrl ? (
|
||||
<a
|
||||
className="ghost-button me-action-button"
|
||||
href={subscriptionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开订阅链接
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="me-link-card">
|
||||
<span className="feature-kicker">SUBSCRIPTION URL</span>
|
||||
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
|
||||
</div>
|
||||
<div className="me-tip-grid">
|
||||
<div className="me-tip-card">
|
||||
<span className="feature-kicker">TIP 01</span>
|
||||
<strong>客户端未唤起时</strong>
|
||||
<p className="muted">确认已安装支持 `clash://` 的客户端,并允许浏览器打开外部应用。</p>
|
||||
</div>
|
||||
<div className="me-tip-card">
|
||||
<span className="feature-kicker">TIP 02</span>
|
||||
<strong>手动导入也可用</strong>
|
||||
<p className="muted">复制上方订阅链接,在 Clash / Mihomo 客户端中粘贴导入即可。</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="me-side-stack">
|
||||
<article className="me-panel">
|
||||
<div className="section-title">
|
||||
<h2>流量概览</h2>
|
||||
</div>
|
||||
<div className="me-metrics-grid">
|
||||
<div className="me-metric-card me-metric-card--paper">
|
||||
<span>总用量</span>
|
||||
<strong>{formatBytes(usedBytes)}</strong>
|
||||
</div>
|
||||
<div className="me-metric-card me-metric-card--warm">
|
||||
<span>剩余额度</span>
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
</div>
|
||||
<div className="me-metric-card me-metric-card--ink">
|
||||
<span>上传</span>
|
||||
<strong>{formatBytes(user.used_tx_bytes)}</strong>
|
||||
</div>
|
||||
<div className="me-metric-card me-metric-card--sage">
|
||||
<span>下载</span>
|
||||
<strong>{formatBytes(user.used_rx_bytes)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="me-panel me-support-card">
|
||||
<span className="feature-kicker">ACCESS</span>
|
||||
<h2>使用说明</h2>
|
||||
<ul className="me-note-list">
|
||||
<li>域名入口适合网页登录与获取订阅。</li>
|
||||
<li>现有节点鉴权仍可继续使用原来的 `IP:3000` 直连地址。</li>
|
||||
<li>如果订阅内容更新,重新点击导入按钮即可刷新客户端配置。</li>
|
||||
</ul>
|
||||
<form action="/logout" method="post">
|
||||
<button className="ghost-button me-action-button me-action-button--full" type="submit">
|
||||
退出登录
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="metric-inline">
|
||||
<span>上传</span>
|
||||
<strong>{formatBytes(user.used_tx_bytes)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>下载</span>
|
||||
<strong>{formatBytes(user.used_rx_bytes)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>剩余额度</span>
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/logout" method="post">
|
||||
<button className="ghost-button" type="submit">
|
||||
退出登录
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
53
deploy/nginx-hy2-panel.conf
Normal file
53
deploy/nginx-hy2-panel.conf
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user