Country Select to API

This commit is contained in:
2026-06-29 18:38:56 +07:00
parent a878461264
commit 16760c16ea
14 changed files with 287 additions and 34 deletions
+137
View File
@@ -0,0 +1,137 @@
// Контракт с бэкендом Netrunner (control-plane).
//
// Базовый URL берётся из VITE_API_BASE_URL (см. .env). По умолчанию — прод.
// Эндпоинт списка серверов закрыт auth_guard (JWT), поэтому клиент анонимно
// бутстрапит токен через Ghost Protocol (/auth/seed/*): при первом запуске
// генерирует seed, дальше переиспользует его для логина. JWT короткоживущий
// (~10 мин), поэтому кешируется и автоматически обновляется по истечении.
const API_BASE = (
import.meta.env.VITE_API_BASE_URL ?? "https://netrunner-vpn.com"
).replace(/\/$/, "");
const API_V1 = `${API_BASE}/api/v1`;
const SEED_KEY = "nrxp_seed";
const TOKEN_KEY = "nrxp_token";
// --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) ---
export interface ServerNode {
id: string;
name: string;
countryCode: string;
ipAddress: string;
port: number;
status: string;
currentLoad: number;
tunnelAddress: string;
tunnelPrefix: number;
tunnelDns: string;
tunnelMtu: number;
}
// Сырой ответ бэкенда (snake_case, сериализация VpnNode по умолчанию).
interface RawVpnNode {
id: string;
name: string;
country_code: string;
ip_address: string;
port: number;
status: string;
current_load: number;
tunnel_address: string;
tunnel_prefix: number;
tunnel_dns: string;
tunnel_mtu: number;
}
function mapNode(n: RawVpnNode): ServerNode {
return {
id: n.id,
name: n.name,
countryCode: n.country_code,
ipAddress: n.ip_address,
port: n.port,
status: n.status,
currentLoad: n.current_load,
tunnelAddress: n.tunnel_address,
tunnelPrefix: n.tunnel_prefix,
tunnelDns: n.tunnel_dns,
tunnelMtu: n.tunnel_mtu,
};
}
// --- Работа с токеном ---
function decodeJwtExp(token: string): number | null {
try {
const payload = token.split(".")[1];
const json = JSON.parse(
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
);
return typeof json.exp === "number" ? json.exp : null;
} catch {
return null;
}
}
function isTokenValid(token: string | null): token is string {
if (!token) return false;
const exp = decodeJwtExp(token);
if (exp === null) return false;
// 30 секунд запаса, чтобы не ловить протухание прямо в полёте.
return exp * 1000 > Date.now() + 30_000;
}
async function generateSeed(): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error(`seed/generate failed: ${res.status}`);
const data = (await res.json()) as { seed: string; token: string };
localStorage.setItem(SEED_KEY, data.seed);
localStorage.setItem(TOKEN_KEY, data.token);
return data.token;
}
async function loginSeed(seed: string): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed }),
});
if (!res.ok) throw new Error(`seed/login failed: ${res.status}`);
const data = (await res.json()) as { token: string };
localStorage.setItem(TOKEN_KEY, data.token);
return data.token;
}
// Возвращает валидный JWT: из кеша, через логин по сохранённому seed
// либо генерируя новый seed при первом запуске.
async function ensureToken(): Promise<string> {
const cached = localStorage.getItem(TOKEN_KEY);
if (isTokenValid(cached)) return cached;
const seed = localStorage.getItem(SEED_KEY);
if (seed) {
try {
return await loginSeed(seed);
} catch {
// Seed мог протухнуть/быть удалён на сервере — перегенерируем.
}
}
return generateSeed();
}
// --- Публичный API ---
export async function fetchNodes(): Promise<ServerNode[]> {
const token = await ensureToken();
const res = await fetch(`${API_V1}/nodes`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`);
const data = (await res.json()) as RawVpnNode[];
return data.map(mapNode);
}