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."); } });