feat: refactor environment variables and streamline API integration

This commit is contained in:
2026-08-20 19:20:37 +08:00
parent 4f702493cf
commit 7b865f638e
3 changed files with 41 additions and 46 deletions
+2 -4
View File
@@ -1,8 +1,6 @@
# Deployment target: "com" or "cn". Builds default to "com" when omitted. # Deployment target: "com" or "cn". Builds default to "com" when omitted.
SITE_VARIANT=com SITE_VARIANT=com
# Build-only secrets used to resolve node IPs into public coordinates. # Build-only secret used to resolve node IPs into public coordinates.
# Never prefix these variables with VITE_, or they may be exposed to the browser. # Never prefix this variable with VITE_, or it may be exposed to the browser.
KOMARI_BASE_URL=https://ops.zhouhaoze.top
KOMARI_API_KEY= KOMARI_API_KEY=
IPINFO_TOKEN=
+39 -40
View File
@@ -45,11 +45,11 @@ export interface NodeLocationsFile {
} }
export interface NodeLocationBuildOptions { export interface NodeLocationBuildOptions {
komariBaseUrl: string;
komariApiKey?: string; komariApiKey?: string;
ipinfoToken?: string;
} }
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
function isPrivateIp(ip: string): boolean { function isPrivateIp(ip: string): boolean {
if (ip.includes(":")) { if (ip.includes(":")) {
const value = ip.toLowerCase(); const value = ip.toLowerCase();
@@ -91,30 +91,37 @@ async function fetchJson(url: string, init?: RequestInit): Promise<Record<string
return response.json() as Promise<Record<string, unknown>>; return response.json() as Promise<Record<string, unknown>>;
} }
function numberValue(value: unknown): number | null {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function geoResult( function geoResult(
latValue: unknown, latValue: unknown,
lngValue: unknown, lngValue: unknown,
city: unknown, cityValue: unknown,
country: unknown, countryValue: unknown,
countryCode: unknown, countryCodeValue: unknown,
): GeoResult | null { ): GeoResult | null {
const lat = numberValue(latValue); const lat = Number(latValue);
const lng = numberValue(lngValue); const lng = Number(lngValue);
if (lat === null || lng === null || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { return {
lat, lat,
lng, lng,
city: typeof city === "string" ? city : "", city: typeof cityValue === "string" ? cityValue : "",
country: typeof country === "string" ? country : "", country: typeof countryValue === "string" ? countryValue : "",
countryCode: typeof countryCode === "string" ? countryCode : "", countryCode: typeof countryCodeValue === "string" ? countryCodeValue : "",
}; };
} }
async function locateWithIpSb(ip: string): Promise<GeoResult | null> {
const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`);
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
}
async function locateWithIpinfo(ip: string): Promise<GeoResult | null> {
const data = await fetchJson(`https://ipinfo.io/${encodeURIComponent(ip)}/json`);
if (typeof data.loc !== "string") return null;
const [lat, lng] = data.loc.split(",");
return geoResult(lat, lng, data.city, data.country, data.country);
}
async function locateWithIpWho(ip: string): Promise<GeoResult | null> { async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
const data = await fetchJson( const data = await fetchJson(
`https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`, `https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`,
@@ -123,28 +130,20 @@ async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
} }
async function locateWithIpSb(ip: string): Promise<GeoResult | null> { async function locateWithIpapi(ip: string): Promise<GeoResult | null> {
const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`); const data = await fetchJson(`https://ipapi.co/${encodeURIComponent(ip)}/json/`);
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); if (data.error === true) return null;
return geoResult(data.latitude, data.longitude, data.city, data.country_name, data.country_code);
} }
async function locateWithIpinfo(ip: string, token: string): Promise<GeoResult | null> { async function locateIp(ip: string): Promise<GeoResult | null> {
const data = await fetchJson(`https://ipinfo.io/${encodeURIComponent(ip)}/json?token=${encodeURIComponent(token)}`); const providers = [locateWithIpSb, locateWithIpinfo, locateWithIpWho, locateWithIpapi];
if (typeof data.loc !== "string") return null;
const [lat, lng] = data.loc.split(",");
return geoResult(lat, lng, data.city, data.country, data.country);
}
async function locateIp(ip: string, ipinfoToken?: string): Promise<GeoResult | null> {
const providers = [() => locateWithIpWho(ip), () => locateWithIpSb(ip)];
if (ipinfoToken) providers.push(() => locateWithIpinfo(ip, ipinfoToken));
for (const provider of providers) { for (const provider of providers) {
try { try {
const location = await provider(); const result = await provider(ip);
if (location) return location; if (result) return result;
} catch { } catch {
// Try the next provider; the country-level fallback remains available in the client. // Continue in provider order; the client keeps its country-level fallback if all fail.
} }
} }
return null; return null;
@@ -165,8 +164,8 @@ async function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item
return results; return results;
} }
async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise<KomariNode[]> { async function fetchKomariNodes(apiKey: string): Promise<KomariNode[]> {
const payload = await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/rpc2`, { const payload = await fetchJson(`${KOMARI_BASE_URL}/api/rpc2`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@@ -180,25 +179,25 @@ async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise<Komari
return Object.values(rpc.result).filter((node) => !node.hidden); return Object.values(rpc.result).filter((node) => !node.hidden);
} }
async function fetchPublicNodes(baseUrl: string): Promise<PublicNode[]> { async function fetchPublicNodes(): Promise<PublicNode[]> {
const payload = (await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/nodes`)) as KomariNodesResponse; const payload = (await fetchJson(`${KOMARI_BASE_URL}/api/nodes`)) as KomariNodesResponse;
if (!Array.isArray(payload.data)) throw new Error("Komari public API returned an invalid node list."); if (!Array.isArray(payload.data)) throw new Error("Komari public API returned an invalid node list.");
return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" })); return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" }));
} }
export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise<NodeLocationsFile> { export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise<NodeLocationsFile> {
const publicNodes = await fetchPublicNodes(options.komariBaseUrl); const publicNodes = await fetchPublicNodes();
if (!options.komariApiKey) { if (!options.komariApiKey) {
return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] }; return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] };
} }
const nodes = await fetchKomariNodes(options.komariBaseUrl, options.komariApiKey); const nodes = await fetchKomariNodes(options.komariApiKey);
const nodesWithIp = nodes.flatMap((node) => { const nodesWithIp = nodes.flatMap((node) => {
const ip = firstPublicIp(node); const ip = firstPublicIp(node);
return ip ? [{ node, ip }] : []; return ip ? [{ node, ip }] : [];
}); });
const uniqueIps = [...new Set(nodesWithIp.map(({ ip }) => ip))]; const uniqueIps = [...new Set(nodesWithIp.map(({ ip }) => ip))];
const resolved = await mapWithConcurrency(uniqueIps, 4, async (ip) => [ip, await locateIp(ip, options.ipinfoToken)] as const); const resolved = await mapWithConcurrency(uniqueIps, 4, async (ip) => [ip, await locateIp(ip)] as const);
const locationsByIp = new Map(resolved.filter((entry): entry is readonly [string, GeoResult] => entry[1] !== null)); const locationsByIp = new Map(resolved.filter((entry): entry is readonly [string, GeoResult] => entry[1] !== null));
const locations = nodesWithIp.flatMap(({ node, ip }) => { const locations = nodesWithIp.flatMap(({ node, ip }) => {
-2
View File
@@ -112,9 +112,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); const env = loadEnv(mode, process.cwd(), "");
const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT); const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT);
const nodeLocationOptions = { const nodeLocationOptions = {
komariBaseUrl: process.env.KOMARI_BASE_URL ?? env.KOMARI_BASE_URL ?? "https://ops.zhouhaoze.top",
komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY, komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY,
ipinfoToken: process.env.IPINFO_TOKEN ?? env.IPINFO_TOKEN,
}; };
return { return {