Files
app_umbrix/app/api/nodes/route.ts
Umbrix Dev 33b20b979f 🔒 Аудит: безопасность, TypeScript, UI, BottomNav
Безопасность:
- proxy: белый список путей (только /sub/*), POST заблокирован
- console.log заменён на logger (утечки URL/данных)
- OnboardingFlow: убраны --tg-theme-* (не существуют в проекте)

TypeScript (0 ошибок):
- tsconfig target es5→es2017 (regex /u flag fix)
- layout.tsx: viewport перенесён в metadata (Next.js 13.5)
- telegram-webhook: fix text possibly undefined
- hooks/useTelegramWebApp: fix Object possibly undefined
- types/telegram: убрана дублирующая Window декларация

UI:
- BottomNav: новый компонент (Назад/Главная/Помощь)
- safe-area-bottom CSS класс добавлен в globals.css
- dashboard: spacer h-20, toast поднят над BottomNav
- OnboardingFlow: цены 149/249/350₽ (были 200/350/500₽)

Очистка:
- page_NEW.tsx удалён локально (не был в git)
2026-02-08 18:59:02 +03:00

134 lines
4.1 KiB
TypeScript

// 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<string, string> = {
'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 }
);
}
}