Country Select to API
This commit is contained in:
+137
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user