diff --git a/app/api/create-user/route.ts b/app/api/create-user/route.ts index ebd74d8..e0a4a44 100644 --- a/app/api/create-user/route.ts +++ b/app/api/create-user/route.ts @@ -1,6 +1,6 @@ // API endpoint для создания нового пользователя VPN // POST /api/create-user -// Принимает: { planType, telegramId, telegramUsername, firstName, lastName } +// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName } // Возвращает: { success: true, token, username, subscriptionUrl } import { NextRequest, NextResponse } from 'next/server'; @@ -9,6 +9,8 @@ import { MARZBAN_PANEL_URL, getSubscriptionUrl } from '@/lib/constants'; import { logger } from '@/lib/logger'; import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban'; +type Period = '1month' | '3months' | '6months' | '1year'; + const MARZBAN_API = MARZBAN_PANEL_URL; const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME; const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD; @@ -19,9 +21,27 @@ if (!ADMIN_USERNAME || !ADMIN_PASSWORD) { export async function POST(request: NextRequest) { try { - const { planType, telegramId, telegramUsername, firstName, lastName, referrerId } = await request.json(); + const { + planType, + period = '1month', // NEW: период оплаты + locationIds = [], // NEW: выбранные локации (ID нод) + telegramId, + telegramUsername, + firstName, + lastName, + referrerId + } = await request.json(); - logger.debug('📥 Received data:', { planType, telegramId, telegramUsername, firstName, lastName, referrerId }); + logger.debug('📥 Received data:', { + planType, + period, + locationIds, + telegramId, + telegramUsername, + firstName, + lastName, + referrerId + }); // 1. Получаем токен админа const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, { @@ -38,8 +58,8 @@ export async function POST(request: NextRequest) { const { access_token } = await tokenResponse.json(); - // 2. Определяем параметры тарифа - const planConfig = getPlanConfig(planType); + // 2. Определяем параметры тарифа с учетом периода + const planConfig = getPlanConfig(planType, period as Period); // 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random) // ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @ @@ -62,7 +82,84 @@ export async function POST(request: NextRequest) { console.log('✅ Generated username:', username); logger.debug('✅ Generated username:', username); logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName }); - // 4. Создаем пользователя в Marzban по ПРАВИЛЬНОЙ схеме + + // 4. Определяем inbounds на основе выбранных локаций + let userInbounds = { + vless: ['VLESS TCP', 'VLESS Reality'], + vmess: ['VMess WS'], + trojan: ['Trojan TCP'] + }; + + // Если locationIds указаны - получаем inbounds для выбранных нод + if (locationIds && locationIds.length > 0) { + try { + // Получить список нод + const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, { + headers: { 'Authorization': `Bearer ${access_token}` }, + }); + + if (nodesResponse.ok) { + const allNodes = await nodesResponse.json(); + const selectedNodes = allNodes.filter((node: any) => locationIds.includes(node.id)); + + logger.debug(`📍 Selected ${selectedNodes.length} nodes:`, selectedNodes.map((n: any) => n.name)); + + // Получить inbounds + const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, { + headers: { 'Authorization': `Bearer ${access_token}` }, + }); + + if (inboundsResponse.ok) { + const allInbounds = await inboundsResponse.json(); + + // Создать mapping inbounds для выбранных нод + userInbounds = { + vless: [], + vmess: [], + trojan: [] + }; + + // Группируем inbounds по протоколу + for (const node of selectedNodes) { + const nodeInbounds = allInbounds.filter((inbound: any) => { + // Ищем inbounds, которые содержат имя ноды в теге + const inboundTag = (inbound.tag || '').toLowerCase(); + const nodeName = (node.name || '').toLowerCase(); + return inboundTag.includes(nodeName) || inboundTag.includes(`node${node.id}`); + }); + + for (const inbound of nodeInbounds) { + const protocol = (inbound.protocol || '').toLowerCase(); + if (protocol.includes('vless') && !userInbounds.vless.includes(inbound.tag)) { + userInbounds.vless.push(inbound.tag); + } else if (protocol.includes('vmess') && !userInbounds.vmess.includes(inbound.tag)) { + userInbounds.vmess.push(inbound.tag); + } else if (protocol.includes('trojan') && !userInbounds.trojan.includes(inbound.tag)) { + userInbounds.trojan.push(inbound.tag); + } + } + } + + // Если не нашли inbounds - используем дефолтные + if (userInbounds.vless.length === 0 && userInbounds.vmess.length === 0 && userInbounds.trojan.length === 0) { + logger.warn('⚠️ No inbounds found for selected nodes, using defaults'); + userInbounds = { + vless: ['VLESS TCP', 'VLESS Reality'], + vmess: ['VMess WS'], + trojan: ['Trojan TCP'] + }; + } else { + logger.debug('✅ Mapped inbounds:', userInbounds); + } + } + } + } catch (error) { + logger.error('❌ Failed to fetch nodes/inbounds, using defaults:', error); + // Используем дефолтные inbounds при ошибке + } + } + + // 5. Создаем пользователя в Marzban const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, { method: 'POST', headers: { @@ -82,12 +179,8 @@ export async function POST(request: NextRequest) { vless: {}, trojan: {} }, - inbounds: { - vless: ['VLESS TCP', 'VLESS Reality'], - vmess: ['VMess WS'], - trojan: ['Trojan TCP'] - }, - note: `Тариф: ${planConfig.name}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`, + inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS + note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`, }), }); @@ -98,10 +191,10 @@ export async function POST(request: NextRequest) { const userData = await createUserResponse.json(); - // 5. Получаем subscription token + // 6. Получаем subscription token const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username; - // 6. Track referral if referrerId provided + // 7. Track referral if referrerId provided if (referrerId) { try { await fetch(`${request.nextUrl.origin}/api/referral/track`, { @@ -139,31 +232,51 @@ export async function POST(request: NextRequest) { } } -function getPlanConfig(planType: PlanType) { +function getPlanConfig(planType: PlanType, period: Period = '1month') { const now = Math.floor(Date.now() / 1000); - const configs: Record = { + // Базовые параметры тарифов + const basePlans = { trial: { name: '7 дней бесплатно', - expireTimestamp: now + (7 * 24 * 60 * 60), // 7 дней - dataLimitBytes: 0, // безлимит + dataLimitGB: 0, // безлимит + durationDays: 7, }, start: { name: 'Базовый', - expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней - dataLimitBytes: 50 * 1024 * 1024 * 1024, // 50 ГБ + dataLimitGB: 50, + durationDays: 30, }, plus: { name: 'Расширенный', - expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней - dataLimitBytes: 299 * 1024 * 1024 * 1024, // 299 ГБ + dataLimitGB: 299, + durationDays: 30, }, max: { name: 'Премиум', - expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней - dataLimitBytes: 0, // безлимит + dataLimitGB: 0, // безлимит + durationDays: 30, }, }; - return configs[planType] || configs.trial; + // Множители для периодов + const periodMultipliers: Record = { + '1month': 1, + '3months': 3, + '6months': 6, + '1year': 12, + }; + + const plan = basePlans[planType as keyof typeof basePlans] || basePlans.trial; + const multiplier = periodMultipliers[period] || 1; + + // Расчет итоговой длительности + const totalDays = plan.durationDays * multiplier; + + return { + name: plan.name, + expireTimestamp: now + (totalDays * 24 * 60 * 60), + dataLimitBytes: plan.dataLimitGB > 0 ? plan.dataLimitGB * 1024 * 1024 * 1024 : 0, + periodDays: totalDays, + }; } diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts new file mode 100644 index 0000000..7e9d33e --- /dev/null +++ b/app/api/nodes/route.ts @@ -0,0 +1,130 @@ +// 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 = [...flag].map(char => char.codePointAt(0)! - 0x1F1E6 + 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 } + ); + } +}