// API endpoint для создания нового пользователя VPN // POST /api/create-user // Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName } // Возвращает: { success: true, token, username, subscriptionUrl } import { NextRequest, NextResponse } from 'next/server'; import { randomUUID } from 'crypto'; // Криптографически безопасная генерация UUID 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; if (!ADMIN_USERNAME || !ADMIN_PASSWORD) { throw new Error('MARZBAN_ADMIN_USERNAME and MARZBAN_ADMIN_PASSWORD must be set in .env'); } export async function POST(request: NextRequest) { try { const { planType, period = '1month', locationIds = [], telegramId, telegramUsername, firstName, lastName, referrerId } = await request.json(); logger.debug('📥 Received data:', { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName, referrerId }); // 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(); // 2. Определяем параметры тарифа с учетом периода const planConfig = getPlanConfig(planType, period as Period); // 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random) // ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @ let username: string; let usernameSource: string; if (telegramUsername) { // Есть @username в Telegram - используем БЕЗ @ username = telegramUsername.toLowerCase().replace(/[^a-z0-9_]/g, '_'); usernameSource = 'telegram_username'; } else if (firstName && telegramId) { // Нет username, используем имя + ID const cleanName = firstName.toLowerCase().replace(/[^a-z0-9]/g, '_'); username = `${cleanName}_${telegramId}`; usernameSource = 'firstName_telegramId'; } else if (telegramId) { // Только ID username = `user_${telegramId}`; usernameSource = 'telegramId_only'; } else { // Для тестирования вне Telegram username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`; usernameSource = 'random_timestamp'; } logger.info('👤 USERNAME GENERATION:', { source: usernameSource, username, input: { telegramId, telegramUsername, firstName, lastName } }); // 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: { 'Authorization': `Bearer ${access_token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ username: username, status: 'active', expire: planConfig.expireTimestamp, data_limit: planConfig.dataLimitBytes, data_limit_reset_strategy: 'no_reset', proxies: { vmess: { id: randomUUID() // Криптографически безопасный UUID }, vless: {}, trojan: {} }, inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`, }), }); if (!createUserResponse.ok) { const errorText = await createUserResponse.text(); throw new Error(`Failed to create user: ${errorText}`); } const userData = await createUserResponse.json(); // 6. Получаем subscription token const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username; // 7. Track referral if referrerId provided if (referrerId) { try { await fetch(`${request.nextUrl.origin}/api/referral/track`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, referrer_username: referrerId, }), }); logger.debug('✅ Referral tracked:', { username, referrer: referrerId }); } catch (refError) { logger.error('❌ Referral tracking failed:', refError); // Don't fail user creation if referral tracking fails } } return NextResponse.json({ success: true, token: subscriptionToken, username: username, expiresAt: new Date(planConfig.expireTimestamp * 1000).toISOString(), subscriptionUrl: getSubscriptionUrl(subscriptionToken), }); } catch (error) { logger.error('Create user error:', error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 } ); } } function getPlanConfig(planType: PlanType, period: Period = '1month') { const now = Math.floor(Date.now() / 1000); // Базовые параметры тарифов const basePlans = { trial: { name: '7 дней бесплатно', dataLimitGB: 0, // безлимит durationDays: 7, }, start: { name: 'Базовый', dataLimitGB: 50, durationDays: 30, }, plus: { name: 'Расширенный', dataLimitGB: 299, durationDays: 30, }, max: { name: 'Премиум', dataLimitGB: 0, // безлимит durationDays: 30, }, }; // Множители для периодов 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, }; }