260 lines
8.5 KiB
JavaScript
260 lines
8.5 KiB
JavaScript
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);
|
|
}
|
|
}
|