Initial stun navigation app

This commit is contained in:
zeer
2026-07-12 16:22:32 +08:00
commit 8570eb84da
12 changed files with 2086 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.env
.env.*
data/*.json
*.log
.DS_Store

63
EDGEONE.md Normal file
View File

@@ -0,0 +1,63 @@
# EdgeOne Pages 部署说明
这个项目已经包含 EdgeOne Pages 版本:
- 静态页面:`public/`
- Edge Functions:`edge-functions/api/[[default]].js`
- EdgeOne 配置:`edgeone.json`
## 需要在 EdgeOne 配置的内容
1. 创建一个 EdgeOne Pages / Makers 项目,并导入这个仓库。
2. 创建并绑定 KV Namespace。
3. 绑定 KV 时,变量名填写:
```text
STUN_NAV_KV
```
4. 配置环境变量:
```text
WEBHOOK_TOKEN=换成你自己的随机密钥
```
5. 部署后访问:
```text
https://你的域名/
https://你的域名/admin
```
## Lucky Webhook 示例
简单 URL 参数方式:
```text
https://你的域名/api/webhook/alist?token=你的随机密钥&name=AList&group=文件&url=http://{STUN_你的STUN规则名_ADDR}
```
JSON 方式:
```text
URL: https://你的域名/api/webhook/alist?token=你的随机密钥
Method: POST
Content-Type: application/json
Body:
{
"name": "AList",
"group": "文件",
"url": "http://{STUN_你的STUN规则名_ADDR}",
"note": "家中文件服务"
}
```
## 数据存储
EdgeOne 版本不再使用 `data/links.json`,所有数据会存到 KV 的 `stun_nav_store` 这个 key 里。
本地 Node 版本仍然保留,可以继续用:
```bash
WEBHOOK_TOKEN=你的随机密钥 PORT=8080 npm start
```

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# STUN 导航页
一个给 Lucky STUN 使用的个人导航页。Lucky 的 STUN 地址变化后,通过 webhook 把最新地址推到这里;浏览器打开导航页时,按钮会跳转到最新地址。
## 启动
```bash
WEBHOOK_TOKEN=换成一串随机密钥 PORT=8080 npm start
```
然后访问:
```text
http://服务器IP:8080
```
管理页:
```text
http://服务器IP:8080/admin
```
打开管理页后填写 `WEBHOOK_TOKEN`,即可手动新增、修改、删除导航项,也可以修改浏览器标题、顶部标识、首页标题和副标题。密钥只保存在当前浏览器本地。
## 部署到 EdgeOne Pages
项目已经包含 EdgeOne Pages 改造版本,见 `EDGEONE.md`。
核心变化:
- 静态页面从 `public/` 部署
- `/api/*` 由 `edge-functions/api/[[default]].js` 提供
- 数据保存到 EdgeOne KV,绑定变量名需要设置为 `STUN_NAV_KV`
- 管理密钥使用环境变量 `WEBHOOK_TOKEN`
## Lucky Webhook 配置
推荐让 Lucky 发送 JSON:
```text
URL: http://你的导航页地址:8080/api/webhook/alist?token=换成一串随机密钥
Method: POST
Content-Type: application/json
Body:
{
"name": "AList",
"group": "文件",
"url": "http://{STUN_你的STUN规则名_ADDR}",
"note": "家中文件服务"
}
```
也可以不用 JSON,直接用 URL 参数:
```text
http://你的导航页地址:8080/api/webhook/alist?token=换成一串随机密钥&name=AList&group=文件&url=http://{STUN_你的STUN规则名_ADDR}
```
Lucky 的全局变量需要把 `规则名` 替换成 STUN 穿透规则列表里的真实规则名,不是导航页服务名,也不是环境变量。例如你的 STUN 规则名叫 `alist-web`,就写 `{STUN_alist-web_ADDR}`。
```text
{STUN_规则名_ADDR}
{STUN_规则名_IP}
{STUN_规则名_PORT}
```
## Webhook 支持的字段
| 字段 | 说明 |
| --- | --- |
| `id` | 服务唯一标识;也可以写在 `/api/webhook/:id` 里 |
| `name` | 页面显示名称 |
| `group` | 页面分组 |
| `url` | 完整跳转地址,例如 `http://1.2.3.4:12345` |
| `addr` | 地址加端口,例如 `1.2.3.4:12345` |
| `host` / `ip` + `port` | 分开发送主机和端口 |
| `protocol` / `scheme` | `http` 或 `https`,默认 `http` |
| `path` | 跳转路径,例如 `/admin` |
| `note` | 备注 |
收到的数据会保存在 `data/links.json`。
## 删除导航
删除某个导航项时,用它的 `id` 调用删除接口。`id` 默认来自 `/api/webhook/:id` 里的那一段,例如 `/api/webhook/alist` 创建的导航,删除时就是:
```bash
curl -X DELETE "http://你的导航页地址:8080/api/services/alist?token=换成一串随机密钥"
```
删除刚才测试的百度示例:
```bash
curl -X DELETE "http://你的导航页地址:8080/api/services/baidu?token=换成一串随机密钥"
```
如果不想调用接口,也可以停掉服务后直接编辑 `data/links.json`,删除对应对象,再重新启动服务。
## 安全建议
- 一定要设置 `WEBHOOK_TOKEN`。
- 不建议直接把导航页暴露到公网裸奔,最好放到 HTTPS 反代后面。
- 如果导航页本身也通过 STUN 暴露,至少给入口加访问控制或复杂路径。

View File

@@ -0,0 +1,243 @@
const STORE_KEY = "stun_nav_store";
const DEFAULT_SETTINGS = {
browserTitle: "家中服务导航",
brand: "STUN NAV",
title: "家中服务",
subtitle: "聚合 Lucky STUN 最新入口,一处打开所有家中应用"
};
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
}
});
}
function firstValue(...values) {
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "");
}
function trimText(value, fallback, maxLength = 80) {
const text = String(firstValue(value, fallback)).trim();
return text.slice(0, maxLength);
}
function sanitizeSettings(input = {}) {
return {
browserTitle: trimText(input.browserTitle, DEFAULT_SETTINGS.browserTitle, 80),
brand: trimText(input.brand, DEFAULT_SETTINGS.brand, 40),
title: trimText(input.title, DEFAULT_SETTINGS.title, 80),
subtitle: trimText(input.subtitle, DEFAULT_SETTINGS.subtitle, 140)
};
}
function slugify(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function normalizeUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) return raw;
return `http://${raw}`;
}
function buildUrl(input) {
const directUrl = firstValue(input.url, input.href, input.link, input.target);
if (directUrl) return normalizeUrl(String(directUrl));
const addr = firstValue(input.addr, input.address, input.external, input.stun_addr);
if (addr) return normalizeUrl(`${input.scheme || input.protocol || "http"}://${addr}`);
const host = firstValue(input.host, input.ip, input.stun_ip);
const port = firstValue(input.port, input.stun_port);
if (!host || !port) return "";
const path = firstValue(input.path, input.pathname) || "";
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${host}:${port}${path}`);
}
function getKv(env = {}) {
return env.STUN_NAV_KV || env.stun_nav_kv || globalThis.STUN_NAV_KV || globalThis.stun_nav_kv || globalThis.my_kv;
}
function getWebhookToken(env = {}) {
return env.WEBHOOK_TOKEN || globalThis.WEBHOOK_TOKEN || "";
}
function getRequestToken(request, url) {
const auth = request.headers.get("authorization") || "";
if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
return request.headers.get("x-webhook-token") || url.searchParams.get("token") || "";
}
function timingSafeEqual(a, b) {
const left = String(a);
const right = String(b);
let diff = left.length ^ right.length;
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
diff |= left.charCodeAt(index % left.length || 0) ^ right.charCodeAt(index % right.length || 0);
}
return diff === 0;
}
function isAuthorized(request, url, env) {
const expected = getWebhookToken(env);
if (!expected) return true;
return timingSafeEqual(getRequestToken(request, url), expected);
}
async function readBody(request) {
const raw = (await request.text()).trim();
if (!raw) return {};
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) return JSON.parse(raw);
if (contentType.includes("application/x-www-form-urlencoded")) {
return Object.fromEntries(new URLSearchParams(raw));
}
try {
return JSON.parse(raw);
} catch {
return { url: raw };
}
}
async function readStore(kv) {
if (!kv) {
throw new Error("KV namespace is not bound. Bind it with variable name STUN_NAV_KV.");
}
const data = (await kv.get(STORE_KEY, { type: "json" })) || {};
return {
settings: sanitizeSettings(data.settings || {}),
services: Array.isArray(data.services) ? data.services : []
};
}
async function writeStore(kv, data) {
await kv.put(STORE_KEY, JSON.stringify({
settings: sanitizeSettings(data.settings || {}),
services: Array.isArray(data.services) ? data.services : []
}));
}
function serviceFromWebhook(idFromPath, query, body) {
const merged = { ...query, ...body };
const name = firstValue(merged.name, merged.service, merged.title, idFromPath);
const id = slugify(firstValue(merged.id, merged.key, idFromPath, name));
const url = buildUrl(merged);
const protocol = firstValue(merged.protocol, merged.scheme, "http");
if (!id) return { error: "missing service id or name" };
if (!url) return { error: "missing service url, addr, or host+port" };
return {
service: {
id,
name: String(name || id),
url,
group: String(firstValue(merged.group, merged.category, "默认")),
protocol: String(protocol).replace(/:$/, ""),
note: String(firstValue(merged.note, merged.description) || ""),
updatedAt: new Date().toISOString()
}
};
}
async function listServices(kv) {
const data = await readStore(kv);
return json({
ok: true,
settings: data.settings,
services: data.services.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
group: item.group || "默认",
note: item.note || "",
updatedAt: item.updatedAt || item.createdAt || ""
}))
});
}
async function updateService(request, url, kv, idFromPath) {
const body = request.method === "GET" ? {} : await readBody(request);
const query = Object.fromEntries(url.searchParams);
const result = serviceFromWebhook(idFromPath, query, body);
if (result.error) return json({ ok: false, error: result.error }, 400);
const data = await readStore(kv);
const index = data.services.findIndex((item) => item.id === result.service.id);
if (index >= 0) {
data.services[index] = { ...data.services[index], ...result.service };
} else {
data.services.push({ createdAt: result.service.updatedAt, ...result.service });
}
data.services.sort((a, b) => `${a.group}${a.name}`.localeCompare(`${b.group}${b.name}`, "zh-Hans-CN"));
await writeStore(kv, data);
return json({ ok: true, service: result.service });
}
async function updateSettings(request, kv) {
const body = await readBody(request);
const data = await readStore(kv);
data.settings = sanitizeSettings({ ...data.settings, ...body });
await writeStore(kv, data);
return json({ ok: true, settings: data.settings });
}
async function deleteService(kv, idFromPath) {
const id = slugify(idFromPath);
if (!id) return json({ ok: false, error: "missing service id" }, 400);
const data = await readStore(kv);
const before = data.services.length;
data.services = data.services.filter((item) => item.id !== id);
await writeStore(kv, data);
return json({ ok: true, deleted: before !== data.services.length, id });
}
export async function onRequest({ request, env }) {
try {
const url = new URL(request.url);
const kv = getKv(env);
const path = url.pathname.replace(/^\/api\/?/, "");
const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
if (request.method === "OPTIONS") return json({ ok: true });
if (parts[0] === "services" && parts.length === 1 && request.method === "GET") {
return await listServices(kv);
}
if (parts[0] === "webhook" && ["GET", "POST", "PUT", "PATCH"].includes(request.method)) {
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
return await updateService(request, url, kv, parts[1] || "");
}
if (parts[0] === "settings" && ["POST", "PUT", "PATCH"].includes(request.method)) {
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
return await updateSettings(request, kv);
}
if (parts[0] === "services" && parts[1] && request.method === "DELETE") {
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
return await deleteService(kv, parts[1]);
}
return json({ ok: false, error: "not found" }, 404);
} catch (error) {
return json({ ok: false, error: error.message || "server error" }, 500);
}
}

21
edgeone.json Normal file
View File

@@ -0,0 +1,21 @@
{
"outputDirectory": "./public",
"nodeVersion": "22.11.0",
"rewrites": [
{
"source": "/admin",
"destination": "/admin.html"
}
],
"headers": [
{
"source": "/*",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache"
}
]
}
]
}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "stun-nav",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs",
"dev": "node --watch server.mjs"
},
"engines": {
"node": ">=18"
}
}

138
public/admin.html Normal file
View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>导航管理</title>
<link rel="stylesheet" href="/styles.css?v=6" />
</head>
<body>
<main class="shell admin-shell">
<header class="topbar hero compact-hero">
<div class="hero-copy">
<p id="adminBrand" class="eyebrow">STUN NAV</p>
<h1>导航管理</h1>
<p class="subtitle">调整首页展示内容,也可以手动维护导航入口</p>
</div>
<div class="actions">
<button class="quick-action primary-button compact-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
<button class="quick-action ghost-button compact-button" type="button" data-target="settingsPanel">页面设置</button>
<a class="text-link" href="/">返回导航</a>
<button id="refreshButton" class="icon-button" type="button" title="刷新">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</header>
<section class="admin-console">
<aside class="admin-sidebar">
<label class="field token-field">
<span>管理密钥</span>
<input id="tokenInput" type="password" autocomplete="off" placeholder="填写 WEBHOOK_TOKEN" />
</label>
<nav class="admin-tabs" aria-label="后台栏目">
<button class="tab-button active" type="button" data-target="settingsPanel">页面设置</button>
<button class="tab-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
<button class="tab-button" type="button" data-target="listPanel">导航列表</button>
</nav>
<section id="statusBar" class="status admin-status" aria-live="polite">正在读取导航...</section>
</aside>
<section class="admin-workspace">
<form id="settingsForm" class="admin-panel settings-form admin-section active" data-panel="settingsPanel">
<div class="panel-heading">
<div>
<p class="panel-kicker">DISPLAY</p>
<h2>页面设置</h2>
</div>
</div>
<label class="field">
<span>浏览器标题</span>
<input id="browserTitleInput" name="browserTitle" autocomplete="off" placeholder="家中服务导航" />
</label>
<label class="field">
<span>顶部标识</span>
<input id="brandInput" name="brand" autocomplete="off" placeholder="STUN NAV" />
</label>
<label class="field">
<span>首页标题</span>
<input id="titleInput" name="title" autocomplete="off" placeholder="家中服务" />
</label>
<label class="field full">
<span>首页副标题</span>
<input id="subtitleInput" name="subtitle" autocomplete="off" placeholder="聚合 Lucky STUN 最新入口,一处打开所有家中应用" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit">保存页面设置</button>
</div>
</form>
<form id="serviceForm" class="admin-panel service-form admin-section" data-panel="servicePanel">
<div class="panel-heading">
<div>
<p class="panel-kicker">SERVICE</p>
<h2 id="serviceFormTitle">新增导航</h2>
</div>
</div>
<input id="originalIdInput" type="hidden" />
<label class="field">
<span>ID</span>
<input id="idInput" name="id" autocomplete="off" required placeholder="alist" />
</label>
<label class="field">
<span>名称</span>
<input id="nameInput" name="name" autocomplete="off" required placeholder="AList" />
</label>
<label class="field">
<span>分组</span>
<input id="groupInput" name="group" autocomplete="off" placeholder="文件" />
</label>
<label class="field full">
<span>地址</span>
<input id="urlInput" name="url" autocomplete="off" required placeholder="http://1.2.3.4:12345" />
</label>
<label class="field full">
<span>备注</span>
<input id="noteInput" name="note" autocomplete="off" placeholder="可选" />
</label>
<div class="form-actions">
<button id="resetButton" class="ghost-button" type="button">清空</button>
<button class="primary-button" type="submit">保存导航</button>
</div>
</form>
<section class="admin-section" data-panel="listPanel">
<div class="admin-list-card">
<div class="panel-heading list-heading">
<div>
<p class="panel-kicker">ENTRIES</p>
<h2>导航列表</h2>
</div>
<button id="newFromListButton" class="primary-button compact-button" type="button">新增导航</button>
</div>
<div class="admin-list-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>分组</th>
<th>地址</th>
<th>更新</th>
<th>操作</th>
</tr>
</thead>
<tbody id="serviceRows"></tbody>
</table>
</div>
</div>
</section>
</section>
</section>
</main>
<script src="/admin.js?v=6" type="module"></script>
</body>
</html>

314
public/admin.js Normal file
View File

@@ -0,0 +1,314 @@
const tokenInput = document.querySelector("#tokenInput");
const statusBar = document.querySelector("#statusBar");
const refreshButton = document.querySelector("#refreshButton");
const form = document.querySelector("#serviceForm");
const settingsForm = document.querySelector("#settingsForm");
const rowsEl = document.querySelector("#serviceRows");
const adminBrand = document.querySelector("#adminBrand");
const browserTitleInput = document.querySelector("#browserTitleInput");
const brandInput = document.querySelector("#brandInput");
const titleInput = document.querySelector("#titleInput");
const subtitleInput = document.querySelector("#subtitleInput");
const originalIdInput = document.querySelector("#originalIdInput");
const idInput = document.querySelector("#idInput");
const nameInput = document.querySelector("#nameInput");
const groupInput = document.querySelector("#groupInput");
const urlInput = document.querySelector("#urlInput");
const noteInput = document.querySelector("#noteInput");
const resetButton = document.querySelector("#resetButton");
const serviceFormTitle = document.querySelector("#serviceFormTitle");
const newFromListButton = document.querySelector("#newFromListButton");
const tabButtons = document.querySelectorAll(".tab-button");
const quickActionButtons = document.querySelectorAll(".quick-action");
const adminSections = document.querySelectorAll(".admin-section");
let services = [];
let settings = {};
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function showPanel(panelId) {
for (const button of tabButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const button of quickActionButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const section of adminSections) {
section.classList.toggle("active", section.dataset.panel === panelId);
}
}
function formatTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function getToken() {
return tokenInput.value.trim();
}
function apiUrl(path) {
const url = new URL(path, window.location.origin);
const token = getToken();
if (token) url.searchParams.set("token", token);
return url;
}
function loadSavedToken() {
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || "";
}
function saveToken() {
localStorage.setItem("stun-nav-admin-token", getToken());
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle ? `${settings.browserTitle} - 管理` : "导航管理";
adminBrand.textContent = settings.brand || "STUN NAV";
browserTitleInput.value = settings.browserTitle || "";
brandInput.value = settings.brand || "";
titleInput.value = settings.title || "";
subtitleInput.value = settings.subtitle || "";
}
function clearForm() {
originalIdInput.value = "";
form.reset();
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || tokenInput.value;
serviceFormTitle.textContent = "新增导航";
showPanel("servicePanel");
idInput.focus();
}
function fillForm(service) {
originalIdInput.value = service.id;
idInput.value = service.id;
nameInput.value = service.name || "";
groupInput.value = service.group || "";
urlInput.value = service.url || "";
noteInput.value = service.note || "";
serviceFormTitle.textContent = "编辑导航";
showPanel("servicePanel");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
...(options.headers || {})
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.error || `请求失败:${response.status}`);
}
return data;
}
function renderRows() {
rowsEl.replaceChildren();
if (!services.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 6;
cell.className = "empty-cell";
cell.textContent = "还没有导航项";
row.append(cell);
rowsEl.append(row);
return;
}
for (const service of services) {
const row = document.createElement("tr");
row.innerHTML = `
<td class="mono"></td>
<td></td>
<td></td>
<td class="url-cell"></td>
<td></td>
<td></td>
`;
const cells = row.querySelectorAll("td");
cells[0].textContent = service.id;
cells[1].textContent = service.name || "";
cells[2].textContent = service.group || "";
cells[3].textContent = service.url || "";
cells[4].textContent = formatTime(service.updatedAt);
const actions = document.createElement("div");
actions.className = "row-actions";
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "small-button";
editButton.textContent = "编辑";
editButton.addEventListener("click", () => fillForm(service));
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "small-button danger-button";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", () => deleteService(service));
actions.append(editButton, deleteButton);
cells[5].append(actions);
rowsEl.append(row);
}
}
async function loadServices() {
try {
const data = await requestJson("/api/services", { method: "GET" });
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
renderRows();
setStatus(`已读取 ${services.length} 个导航项`);
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
async function deleteService(service) {
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const confirmed = window.confirm(`确定删除「${service.name || service.id}」吗?`);
if (!confirmed) return;
try {
saveToken();
await requestJson(apiUrl(`/api/services/${encodeURIComponent(service.id)}`), {
method: "DELETE"
});
if (originalIdInput.value === service.id) clearForm();
await loadServices();
setStatus(`已删除 ${service.name || service.id}`);
} catch (error) {
setStatus(error.message || "删除失败", true);
}
}
async function saveService(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const id = idInput.value.trim();
const originalId = originalIdInput.value.trim();
if (!id) {
setStatus("请填写 ID", true);
return;
}
const payload = {
id,
name: nameInput.value.trim(),
group: groupInput.value.trim() || "默认",
url: urlInput.value.trim(),
note: noteInput.value.trim()
};
try {
saveToken();
await requestJson(apiUrl(`/api/webhook/${encodeURIComponent(id)}`), {
method: "POST",
body: JSON.stringify(payload)
});
if (originalId && originalId !== id) {
await requestJson(apiUrl(`/api/services/${encodeURIComponent(originalId)}`), {
method: "DELETE"
});
}
originalIdInput.value = id;
await loadServices();
setStatus(`已保存 ${payload.name || id}`);
clearForm();
showPanel("listPanel");
} catch (error) {
setStatus(error.message || "保存失败", true);
}
}
async function saveSettings(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const payload = {
browserTitle: browserTitleInput.value.trim(),
brand: brandInput.value.trim(),
title: titleInput.value.trim(),
subtitle: subtitleInput.value.trim()
};
try {
saveToken();
const data = await requestJson(apiUrl("/api/settings"), {
method: "PUT",
body: JSON.stringify(payload)
});
applySettings(data.settings || payload);
setStatus("页面设置已保存");
} catch (error) {
setStatus(error.message || "保存页面设置失败", true);
}
}
loadSavedToken();
loadServices();
tokenInput.addEventListener("change", saveToken);
refreshButton.addEventListener("click", loadServices);
resetButton.addEventListener("click", clearForm);
newFromListButton.addEventListener("click", clearForm);
form.addEventListener("submit", saveService);
settingsForm.addEventListener("submit", saveSettings);
for (const button of tabButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}
for (const button of quickActionButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}

118
public/app.js Normal file
View File

@@ -0,0 +1,118 @@
const servicesEl = document.querySelector("#services");
const statusBar = document.querySelector("#statusBar");
const searchInput = document.querySelector("#searchInput");
const refreshButton = document.querySelector("#refreshButton");
const groupTemplate = document.querySelector("#groupTemplate");
const cardTemplate = document.querySelector("#cardTemplate");
const appBrand = document.querySelector("#appBrand");
const appTitle = document.querySelector("#appTitle");
const appSubtitle = document.querySelector("#appSubtitle");
let services = [];
let settings = {};
function formatTime(value) {
if (!value) return "尚未更新";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle || "家中服务导航";
appBrand.textContent = settings.brand || "STUN NAV";
appTitle.textContent = settings.title || "家中服务";
appSubtitle.textContent = settings.subtitle || "";
appSubtitle.hidden = !appSubtitle.textContent.trim();
}
function serviceMatches(service, keyword) {
if (!keyword) return true;
const text = `${service.name} ${service.group} ${service.note} ${service.url}`.toLowerCase();
return text.includes(keyword.toLowerCase());
}
function groupServices(items) {
return items.reduce((groups, service) => {
const group = service.group || "默认";
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(service);
return groups;
}, new Map());
}
async function copyUrl(url, button) {
await navigator.clipboard.writeText(url);
const oldTitle = button.title;
button.title = "已复制";
setTimeout(() => {
button.title = oldTitle;
}, 1200);
}
function render() {
const keyword = searchInput.value.trim();
const filtered = services.filter((service) => serviceMatches(service, keyword));
servicesEl.replaceChildren();
if (!filtered.length) {
setStatus(services.length ? "没有匹配的服务" : "还没有收到 Lucky 推送的地址");
return;
}
setStatus(`${filtered.length} 个服务可用,最近刷新 ${formatTime(new Date().toISOString())}`);
for (const [group, items] of groupServices(filtered)) {
const groupNode = groupTemplate.content.firstElementChild.cloneNode(true);
groupNode.querySelector("h2").textContent = group;
const grid = groupNode.querySelector(".grid");
for (const service of items) {
const card = cardTemplate.content.firstElementChild.cloneNode(true);
card.querySelector(".service-mark").dataset.initial = (service.name || service.id || "?").trim().slice(0, 1).toUpperCase();
card.querySelector("h3").textContent = service.name;
card.querySelector(".note").textContent = service.note || "";
card.querySelector(".meta").textContent = `${service.url} · ${formatTime(service.updatedAt)}`;
const openButton = card.querySelector(".open-button");
openButton.href = service.url;
const copyButton = card.querySelector(".copy-button");
copyButton.addEventListener("click", () => copyUrl(service.url, copyButton));
grid.append(card);
}
servicesEl.append(groupNode);
}
}
async function loadServices() {
try {
setStatus("正在读取最新地址...");
const response = await fetch("/api/services", { cache: "no-store" });
if (!response.ok) throw new Error(`读取失败:${response.status}`);
const data = await response.json();
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
render();
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
searchInput.addEventListener("input", render);
refreshButton.addEventListener("click", loadServices);
loadServices();
setInterval(loadServices, 30000);

75
public/index.html Normal file
View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>家中服务导航</title>
<link rel="stylesheet" href="/styles.css?v=3" />
</head>
<body>
<main class="shell">
<header class="topbar hero">
<div class="hero-copy">
<p id="appBrand" class="eyebrow">STUN NAV</p>
<h1 id="appTitle">家中服务</h1>
<p id="appSubtitle" class="subtitle">聚合 Lucky STUN 最新入口,一处打开所有家中应用</p>
</div>
<div class="actions">
<a class="text-link" href="/admin">管理</a>
<label class="search">
<span class="search-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
<input id="searchInput" type="search" placeholder="搜索服务" autocomplete="off" />
</label>
<button id="refreshButton" class="icon-button" type="button" title="刷新">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</header>
<section id="statusBar" class="status" aria-live="polite">正在读取最新地址...</section>
<section id="services" class="services"></section>
</main>
<template id="groupTemplate">
<section class="group">
<h2></h2>
<div class="grid"></div>
</section>
</template>
<template id="cardTemplate">
<article class="service-card">
<div class="card-main">
<div class="service-mark" aria-hidden="true"></div>
<div class="service-text">
<h3></h3>
<p class="note"></p>
<p class="meta"></p>
</div>
</div>
<div class="card-actions">
<button class="icon-button copy-button" type="button" title="复制地址">
<svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<a class="open-button" target="_blank" rel="noreferrer">
<span>打开</span>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M7 17 17 7M9 7h8v8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</div>
</article>
</template>
<script src="/app.js?v=3" type="module"></script>
</body>
</html>

652
public/styles.css Normal file
View File

@@ -0,0 +1,652 @@
:root {
color-scheme: light;
--bg: #f4f6f2;
--surface: #ffffff;
--surface-soft: #f9faf7;
--ink: #17211f;
--muted: #66736f;
--line: #dde4df;
--accent: #117c6f;
--accent-strong: #095f55;
--indigo: #405f9f;
--amber: #c47a2c;
--danger: #b23b4a;
--shadow-sm: 0 8px 22px rgba(23, 33, 31, 0.06);
--shadow-md: 0 20px 48px rgba(23, 33, 31, 0.1);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(180deg, rgba(17, 124, 111, 0.1), transparent 320px),
linear-gradient(120deg, rgba(196, 122, 44, 0.09), transparent 360px),
var(--bg);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
a {
font: inherit;
}
input {
min-width: 0;
}
.shell {
width: min(1160px, calc(100vw - 32px));
margin: 0 auto;
padding: 30px 0 56px;
}
.admin-shell {
width: min(1220px, calc(100vw - 32px));
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.hero {
margin-bottom: 22px;
padding: 26px;
border: 1px solid rgba(221, 228, 223, 0.9);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(249, 250, 247, 0.72)),
var(--surface);
box-shadow: var(--shadow-md);
}
.compact-hero {
margin-bottom: 18px;
}
.hero-copy {
min-width: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 850;
letter-spacing: 0;
}
h1 {
margin: 0;
font-size: clamp(34px, 5vw, 58px);
line-height: 1;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.subtitle {
max-width: 650px;
margin: 12px 0 0;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.search {
display: flex;
align-items: center;
width: min(340px, 38vw);
min-height: 44px;
gap: 10px;
padding: 0 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-sm);
}
.search-icon {
display: inline-flex;
color: var(--muted);
}
.search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
}
.text-link,
.icon-button,
.ghost-button,
.small-button,
.primary-button,
.open-button {
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.text-link {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 4px;
color: var(--accent);
font-weight: 780;
text-decoration: none;
}
.text-link:hover {
color: var(--accent-strong);
}
.icon-button {
display: inline-grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.icon-button:hover,
.ghost-button:hover,
.small-button:hover {
transform: translateY(-1px);
border-color: rgba(17, 124, 111, 0.45);
color: var(--accent);
}
.status {
display: flex;
align-items: center;
min-height: 42px;
margin-bottom: 22px;
padding: 10px 14px;
border: 1px solid rgba(221, 228, 223, 0.9);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
color: var(--muted);
font-size: 14px;
box-shadow: var(--shadow-sm);
}
.status.error {
border-color: rgba(178, 59, 74, 0.35);
color: var(--danger);
}
.services {
display: grid;
gap: 28px;
}
.group h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
color: #24302d;
font-size: 16px;
}
.group h2::before {
content: "";
width: 8px;
height: 20px;
border-radius: 8px;
background: var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.service-card {
display: grid;
min-height: 176px;
align-content: space-between;
gap: 18px;
padding: 18px;
border: 1px solid rgba(221, 228, 223, 0.95);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-sm);
}
.service-card:hover {
border-color: rgba(17, 124, 111, 0.36);
box-shadow: var(--shadow-md);
}
.card-main {
display: flex;
min-width: 0;
gap: 13px;
}
.service-mark {
display: grid;
flex: 0 0 auto;
width: 44px;
height: 44px;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 18px;
font-weight: 850;
}
.service-mark::before {
content: attr(data-initial);
}
.service-card:nth-child(3n + 2) .service-mark {
background: var(--amber);
}
.service-card:nth-child(3n) .service-mark {
background: var(--indigo);
}
.service-text {
min-width: 0;
}
.service-text h3 {
margin: 0 0 8px;
overflow-wrap: anywhere;
font-size: 19px;
line-height: 1.22;
}
.note,
.meta {
margin: 0;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.note {
color: #43514d;
}
.note:empty {
display: none;
}
.card-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.open-button,
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
gap: 8px;
padding: 0 16px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 780;
text-decoration: none;
cursor: pointer;
}
.compact-button {
min-height: 38px;
padding: 0 12px;
font-size: 13px;
}
.quick-action.active {
outline: 2px solid rgba(17, 124, 111, 0.18);
outline-offset: 2px;
}
.open-button:hover,
.primary-button:hover {
transform: translateY(-1px);
border-color: var(--accent-strong);
background: var(--accent-strong);
}
.admin-console {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 18px;
}
.admin-sidebar,
.admin-list-card,
.admin-panel {
border: 1px solid rgba(221, 228, 223, 0.95);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-sm);
}
.admin-sidebar {
position: sticky;
top: 18px;
align-self: start;
padding: 16px;
}
.token-field {
margin-bottom: 16px;
}
.admin-tabs {
display: grid;
gap: 8px;
}
.tab-button {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 44px;
padding: 0 13px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--ink);
font-weight: 780;
cursor: pointer;
text-align: left;
}
.tab-button:hover {
background: var(--surface-soft);
}
.tab-button.active {
border-color: rgba(17, 124, 111, 0.28);
background: rgba(17, 124, 111, 0.1);
color: var(--accent-strong);
}
.admin-status {
margin: 16px 0 0;
box-shadow: none;
}
.admin-workspace {
min-width: 0;
}
.admin-section {
display: none;
}
.admin-section.active {
display: block;
}
.admin-panel.admin-section.active {
display: grid;
}
.admin-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.admin-section:not(.active) {
display: none;
}
.auth-panel {
padding: 14px;
}
.panel-heading {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.panel-kicker {
margin: 0 0 5px;
color: var(--accent);
font-size: 11px;
font-weight: 850;
letter-spacing: 0;
}
.settings-form,
.service-form {
align-items: end;
}
.field {
display: grid;
gap: 7px;
min-width: 0;
}
.field.full {
grid-column: 1 / -1;
}
.field span {
color: var(--muted);
font-size: 13px;
font-weight: 720;
}
.field input {
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
color: var(--ink);
outline: 0;
}
.field input:focus {
border-color: rgba(17, 124, 111, 0.6);
background: #fff;
}
.form-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
gap: 10px;
}
.ghost-button,
.small-button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.ghost-button {
padding: 0 16px;
}
.small-button {
padding: 0 12px;
font-size: 13px;
font-weight: 760;
}
.danger-button {
border-color: rgba(178, 59, 74, 0.32);
color: var(--danger);
}
.danger-button:hover {
border-color: rgba(178, 59, 74, 0.58);
color: var(--danger);
}
.admin-list-wrap {
overflow-x: auto;
}
.admin-list-card {
padding: 18px;
}
.list-heading {
margin-bottom: 12px;
}
.admin-table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
.admin-table th {
background: var(--surface-soft);
color: var(--muted);
font-size: 12px;
font-weight: 820;
}
.admin-table tr:last-child td {
border-bottom: 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.url-cell {
max-width: 380px;
overflow-wrap: anywhere;
color: var(--muted);
}
.row-actions {
display: flex;
gap: 8px;
}
.empty-cell {
color: var(--muted);
text-align: center;
}
@media (max-width: 760px) {
.shell,
.admin-shell {
width: min(100vw - 24px, 1220px);
padding-top: 18px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.hero {
padding: 20px;
}
.actions {
justify-content: stretch;
width: 100%;
}
.search {
width: 100%;
flex: 1 1 220px;
}
.admin-panel {
grid-template-columns: 1fr;
}
.admin-console {
grid-template-columns: 1fr;
}
.admin-sidebar {
position: static;
}
.admin-tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tab-button {
justify-content: center;
padding: 0 8px;
text-align: center;
}
.form-actions {
justify-content: stretch;
}
.form-actions .ghost-button,
.form-actions .primary-button {
flex: 1;
}
.grid {
grid-template-columns: 1fr;
}
}

340
server.mjs Normal file
View File

@@ -0,0 +1,340 @@
import { createServer } from "node:http";
import { readFile, writeFile, mkdir, stat, rename } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const PORT = Number(process.env.PORT || 8080);
const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || "";
const DATA_DIR = join(__dirname, "data");
const DATA_FILE = join(DATA_DIR, "links.json");
const PUBLIC_DIR = join(__dirname, "public");
const DEFAULT_SETTINGS = {
browserTitle: "家中服务导航",
brand: "STUN NAV",
title: "家中服务",
subtitle: "聚合 Lucky STUN 最新入口,一处打开所有家中应用"
};
const contentTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml; charset=utf-8"
};
async function ensureDataFile() {
await mkdir(DATA_DIR, { recursive: true });
try {
await stat(DATA_FILE);
} catch {
await writeFile(DATA_FILE, JSON.stringify({ settings: DEFAULT_SETTINGS, services: [] }, null, 2));
}
}
async function readStore() {
await ensureDataFile();
const raw = await readFile(DATA_FILE, "utf8");
const data = JSON.parse(raw || "{}");
return {
settings: sanitizeSettings(data.settings || {}),
services: Array.isArray(data.services) ? data.services : []
};
}
async function writeStore(data) {
await ensureDataFile();
const tempFile = `${DATA_FILE}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempFile, JSON.stringify(data, null, 2));
await rename(tempFile, DATA_FILE);
}
function sendJson(res, status, payload) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
res.end(JSON.stringify(payload));
}
function sendText(res, status, text) {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
res.end(text);
}
function unauthorized(res) {
sendJson(res, 401, { ok: false, error: "invalid webhook token" });
}
function getToken(req, url) {
const auth = req.headers.authorization || "";
if (auth.toLowerCase().startsWith("bearer ")) {
return auth.slice(7).trim();
}
return req.headers["x-webhook-token"] || url.searchParams.get("token") || "";
}
function isWebhookAuthorized(req, url) {
if (!WEBHOOK_TOKEN) return true;
const actual = crypto.createHash("sha256").update(String(getToken(req, url))).digest();
const expected = crypto.createHash("sha256").update(WEBHOOK_TOKEN).digest();
return crypto.timingSafeEqual(
actual,
expected
);
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
if (Buffer.concat(chunks).length > 1024 * 1024) {
throw new Error("request body too large");
}
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (!raw) return {};
const contentType = req.headers["content-type"] || "";
if (contentType.includes("application/json")) {
return JSON.parse(raw);
}
if (contentType.includes("application/x-www-form-urlencoded")) {
return Object.fromEntries(new URLSearchParams(raw));
}
try {
return JSON.parse(raw);
} catch {
return { url: raw };
}
}
function slugify(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function firstValue(...values) {
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "");
}
function trimText(value, fallback, maxLength = 80) {
const text = String(firstValue(value, fallback)).trim();
return text.slice(0, maxLength);
}
function sanitizeSettings(input = {}) {
return {
browserTitle: trimText(input.browserTitle, DEFAULT_SETTINGS.browserTitle, 80),
brand: trimText(input.brand, DEFAULT_SETTINGS.brand, 40),
title: trimText(input.title, DEFAULT_SETTINGS.title, 80),
subtitle: trimText(input.subtitle, DEFAULT_SETTINGS.subtitle, 140)
};
}
function buildUrl(input) {
const directUrl = firstValue(input.url, input.href, input.link, input.target);
if (directUrl) return normalizeUrl(String(directUrl));
const addr = firstValue(input.addr, input.address, input.external, input.stun_addr);
if (addr) {
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${addr}`);
}
const host = firstValue(input.host, input.ip, input.stun_ip);
const port = firstValue(input.port, input.stun_port);
if (!host || !port) return "";
const path = firstValue(input.path, input.pathname) || "";
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${host}:${port}${path}`);
}
function normalizeUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) return raw;
return `http://${raw}`;
}
function serviceFromWebhook(idFromPath, query, body) {
const merged = { ...query, ...body };
const name = firstValue(merged.name, merged.service, merged.title, idFromPath);
const id = slugify(firstValue(merged.id, merged.key, idFromPath, name));
const url = buildUrl(merged);
const protocol = firstValue(merged.protocol, merged.scheme, "http");
if (!id) {
return { error: "missing service id or name" };
}
if (!url) {
return { error: "missing service url, addr, or host+port" };
}
return {
service: {
id,
name: String(name || id),
url,
group: String(firstValue(merged.group, merged.category, "默认")),
protocol: String(protocol).replace(/:$/, ""),
note: String(firstValue(merged.note, merged.description) || ""),
updatedAt: new Date().toISOString()
}
};
}
async function handleWebhook(req, res, url, idFromPath) {
if (!isWebhookAuthorized(req, url)) {
unauthorized(res);
return;
}
let body = {};
if (req.method !== "GET") {
body = await readBody(req);
}
const query = Object.fromEntries(url.searchParams);
const result = serviceFromWebhook(idFromPath, query, body);
if (result.error) {
sendJson(res, 400, { ok: false, error: result.error });
return;
}
const data = await readStore();
const index = data.services.findIndex((item) => item.id === result.service.id);
if (index >= 0) {
data.services[index] = { ...data.services[index], ...result.service };
} else {
data.services.push({ createdAt: result.service.updatedAt, ...result.service });
}
data.services.sort((a, b) => `${a.group}${a.name}`.localeCompare(`${b.group}${b.name}`, "zh-Hans-CN"));
await writeStore(data);
sendJson(res, 200, { ok: true, service: result.service });
}
async function handleList(res) {
const data = await readStore();
sendJson(res, 200, {
ok: true,
settings: data.settings,
services: data.services.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
group: item.group || "默认",
note: item.note || "",
updatedAt: item.updatedAt || item.createdAt || ""
}))
});
}
async function handleUpdateSettings(req, res, url) {
if (!isWebhookAuthorized(req, url)) {
unauthorized(res);
return;
}
const body = await readBody(req);
const data = await readStore();
data.settings = sanitizeSettings({ ...data.settings, ...body });
await writeStore(data);
sendJson(res, 200, { ok: true, settings: data.settings });
}
async function handleDelete(req, res, url, idFromPath) {
if (!isWebhookAuthorized(req, url)) {
unauthorized(res);
return;
}
const id = slugify(idFromPath);
if (!id) {
sendJson(res, 400, { ok: false, error: "missing service id" });
return;
}
const data = await readStore();
const before = data.services.length;
data.services = data.services.filter((item) => item.id !== id);
await writeStore(data);
sendJson(res, 200, {
ok: true,
deleted: before !== data.services.length,
id
});
}
async function serveStatic(req, res, url) {
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/") pathname = "/index.html";
if (pathname === "/admin") pathname = "/admin.html";
const filePath = normalize(join(PUBLIC_DIR, pathname));
if (!filePath.startsWith(PUBLIC_DIR)) {
sendText(res, 403, "Forbidden");
return;
}
try {
const content = await readFile(filePath);
res.writeHead(200, {
"content-type": contentTypes[extname(filePath)] || "application/octet-stream",
"cache-control": "no-cache"
});
res.end(content);
} catch {
sendText(res, 404, "Not found");
}
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
const webhookMatch = url.pathname.match(/^\/api\/webhook(?:\/([^/]+))?\/?$/);
if (webhookMatch && ["GET", "POST", "PUT", "PATCH"].includes(req.method || "")) {
await handleWebhook(req, res, url, webhookMatch[1] ? decodeURIComponent(webhookMatch[1]) : "");
return;
}
if (url.pathname === "/api/services" && req.method === "GET") {
await handleList(res);
return;
}
if (url.pathname === "/api/settings" && ["POST", "PUT", "PATCH"].includes(req.method || "")) {
await handleUpdateSettings(req, res, url);
return;
}
const deleteMatch = url.pathname.match(/^\/api\/services\/([^/]+)\/?$/);
if (deleteMatch && req.method === "DELETE") {
await handleDelete(req, res, url, decodeURIComponent(deleteMatch[1]));
return;
}
await serveStatic(req, res, url);
} catch (error) {
sendJson(res, 500, { ok: false, error: error.message || "server error" });
}
});
server.listen(PORT, () => {
console.log(`STUN navigation is running at http://localhost:${PORT}`);
if (!WEBHOOK_TOKEN) {
console.log("WEBHOOK_TOKEN is not set. Set it before exposing this service.");
}
});