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 queryToObject(search = "") { const query = {}; const raw = String(search || "").replace(/^\?/, ""); if (!raw) return query; for (const pair of raw.split("&")) { if (!pair) continue; const [key, ...valueParts] = pair.split("="); const name = decodeURIComponent(key.replace(/\+/g, " ")); const value = decodeURIComponent(valueParts.join("=").replace(/\+/g, " ")); query[name] = value; } return query; } 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 = queryToObject(url.search); 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); } }