Initial stun navigation app
This commit is contained in:
340
server.mjs
Normal file
340
server.mjs
Normal 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.");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user