commit 8570eb84da0a3ff983688c12e166a8ca52f9276e Author: zeer Date: Sun Jul 12 16:22:32 2026 +0800 Initial stun navigation app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d31ab77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +data/*.json +*.log +.DS_Store diff --git a/EDGEONE.md b/EDGEONE.md new file mode 100644 index 0000000..a9016f4 --- /dev/null +++ b/EDGEONE.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..fad8b1f --- /dev/null +++ b/README.md @@ -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 暴露,至少给入口加访问控制或复杂路径。 diff --git a/edge-functions/api/[[default]].js b/edge-functions/api/[[default]].js new file mode 100644 index 0000000..cf2341e --- /dev/null +++ b/edge-functions/api/[[default]].js @@ -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); + } +} diff --git a/edgeone.json b/edgeone.json new file mode 100644 index 0000000..3cf3fb9 --- /dev/null +++ b/edgeone.json @@ -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" + } + ] + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7470c6f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..b090338 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,138 @@ + + + + + + 导航管理 + + + +
+
+
+

STUN NAV

+

导航管理

+

调整首页展示内容,也可以手动维护导航入口

+
+
+ + + 返回导航 + +
+
+ +
+ + +
+
+
+
+

DISPLAY

+

页面设置

+
+
+ + + + +
+ +
+
+ +
+
+
+

SERVICE

+

新增导航

+
+
+ + + + + + +
+ + +
+
+ +
+
+
+
+

ENTRIES

+

导航列表

+
+ +
+
+ + + + + + + + + + + + +
ID名称分组地址更新操作
+
+
+
+
+
+
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..323012f --- /dev/null +++ b/public/admin.js @@ -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 = ` + + + + + + + `; + + 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); + }); +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7e6be60 --- /dev/null +++ b/public/app.js @@ -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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..388d9bc --- /dev/null +++ b/public/index.html @@ -0,0 +1,75 @@ + + + + + + 家中服务导航 + + + +
+
+
+

STUN NAV

+

家中服务

+

聚合 Lucky STUN 最新入口,一处打开所有家中应用

+
+
+ 管理 + + +
+
+ +
正在读取最新地址...
+
+
+ + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..9224430 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..3c08bf3 --- /dev/null +++ b/server.mjs @@ -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."); + } +});