// API endpoint для получения списка доступных локаций (нод) из Marzban // GET /api/nodes // Возвращает: { success: true, locations: [...] } import { NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; const MARZBAN_API = process.env.MARZBAN_PANEL_URL || process.env.MARZBAN_API_URL; const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME; const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD; if (!ADMIN_USERNAME || !ADMIN_PASSWORD) { throw new Error('MARZBAN_ADMIN_USERNAME and MARZBAN_ADMIN_PASSWORD must be set in .env'); } interface MarzbanNode { id: number; name: string; address: string; port: number; api_port: number; status: 'connected' | 'connecting' | 'disconnected' | 'error'; message: string | null; xray_version: string; } interface Location { id: number; name: string; address: string; ping: string; country: string; flag: string; status: string; } // Mapping для ping'ов (в production можно делать реальный ping check) const PING_MAP: Record = { '193.168.175.128': '15ms', // NL '194.113.210.187': '120ms', // US '103.6.169.78': '160ms', // Asia // Добавить остальные ноды по мере появления }; // Извлечь код страны из emoji флага function extractCountryCode(nodeName: string): string { // Ищем emoji флаг в названии (🇺🇸, 🇳🇱, etc.) const flagMatch = nodeName.match(/[\u{1F1E6}-\u{1F1FF}]{2}/u); if (!flagMatch) return 'Unknown'; // Конвертируем emoji флаг в код страны const flag = flagMatch[0]; const codePoints = Array.from(flag).map(char => { const codePoint = char.codePointAt(0); return codePoint ? codePoint - 0x1F1E6 + 65 : 65; }); return String.fromCharCode(...codePoints); } // Извлечь emoji флаг из названия function extractFlag(nodeName: string): string { const flagMatch = nodeName.match(/[\u{1F1E6}-\u{1F1FF}]{2}/u); return flagMatch ? flagMatch[0] : '🌍'; } export async function GET() { try { logger.debug('📡 Fetching nodes from Marzban API...'); // 1. Получить токен админа const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`, }); if (!tokenResponse.ok) { throw new Error('Failed to get admin token'); } const { access_token } = await tokenResponse.json(); logger.debug('✅ Admin token obtained'); // 2. Получить список нод const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, { headers: { 'Authorization': `Bearer ${access_token}`, }, }); if (!nodesResponse.ok) { throw new Error(`Failed to fetch nodes: ${nodesResponse.statusText}`); } const nodes: MarzbanNode[] = await nodesResponse.json(); logger.debug(`📊 Fetched ${nodes.length} nodes from Marzban`); // 3. Форматировать для фронтенда const locations: Location[] = nodes .filter(node => node.status === 'connected') // Только активные ноды .map(node => ({ id: node.id, name: node.name, address: node.address, ping: PING_MAP[node.address] || '50ms', // Дефолтный ping если не в мапе country: extractCountryCode(node.name), flag: extractFlag(node.name), status: node.status, })); logger.debug(`✅ Formatted ${locations.length} active locations`); return NextResponse.json({ success: true, locations, total: locations.length, }); } catch (error) { logger.error('❌ Failed to fetch nodes:', error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Failed to fetch nodes', locations: [], }, { status: 500 } ); } }