2026-02-04 05:25:10 +03:00
|
|
|
|
// API endpoint для создания нового пользователя VPN
|
|
|
|
|
|
// POST /api/create-user
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName }
|
2026-02-04 05:25:10 +03:00
|
|
|
|
// Возвращает: { success: true, token, username, subscriptionUrl }
|
|
|
|
|
|
|
2026-02-04 05:03:56 +03:00
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
2026-02-04 05:25:10 +03:00
|
|
|
|
import { randomUUID } from 'crypto'; // Криптографически безопасная генерация UUID
|
2026-02-04 05:03:56 +03:00
|
|
|
|
import { MARZBAN_PANEL_URL, getSubscriptionUrl } from '@/lib/constants';
|
|
|
|
|
|
import { logger } from '@/lib/logger';
|
|
|
|
|
|
import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban';
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
type Period = '1month' | '3months' | '6months' | '1year';
|
|
|
|
|
|
|
2026-02-04 05:03:56 +03:00
|
|
|
|
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 {
|
2026-02-08 09:18:55 +03:00
|
|
|
|
const {
|
|
|
|
|
|
planType,
|
2026-02-08 18:59:02 +03:00
|
|
|
|
period = '1month',
|
|
|
|
|
|
locationIds = [],
|
2026-02-08 09:18:55 +03:00
|
|
|
|
telegramId,
|
|
|
|
|
|
telegramUsername,
|
|
|
|
|
|
firstName,
|
|
|
|
|
|
lastName,
|
|
|
|
|
|
referrerId
|
|
|
|
|
|
} = await request.json();
|
2026-02-04 05:03:56 +03:00
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
logger.debug('📥 Received data:', {
|
|
|
|
|
|
planType,
|
|
|
|
|
|
period,
|
|
|
|
|
|
locationIds,
|
|
|
|
|
|
telegramId,
|
|
|
|
|
|
telegramUsername,
|
|
|
|
|
|
firstName,
|
|
|
|
|
|
lastName,
|
|
|
|
|
|
referrerId
|
|
|
|
|
|
});
|
2026-02-04 05:03:56 +03:00
|
|
|
|
|
2026-02-09 06:38:35 +03:00
|
|
|
|
// ⚠️ ВАЛИДАЦИЯ: telegramId обязателен!
|
|
|
|
|
|
if (!telegramId) {
|
|
|
|
|
|
logger.error('❌ VALIDATION FAILED: No telegramId provided');
|
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
|
{
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
error: 'Telegram ID is required. Please open the app through Telegram bot.'
|
|
|
|
|
|
},
|
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 05:03:56 +03:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// 2. Определяем параметры тарифа с учетом периода
|
|
|
|
|
|
const planConfig = getPlanConfig(planType, period as Period);
|
2026-02-04 05:03:56 +03:00
|
|
|
|
|
|
|
|
|
|
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
|
|
|
|
|
|
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
|
|
|
|
|
|
let username: string;
|
2026-02-09 05:06:46 +03:00
|
|
|
|
let usernameSource: string;
|
|
|
|
|
|
|
2026-02-04 05:03:56 +03:00
|
|
|
|
if (telegramUsername) {
|
|
|
|
|
|
// Есть @username в Telegram - используем БЕЗ @
|
|
|
|
|
|
username = telegramUsername.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
2026-02-09 05:06:46 +03:00
|
|
|
|
usernameSource = 'telegram_username';
|
2026-02-04 05:03:56 +03:00
|
|
|
|
} else if (firstName && telegramId) {
|
|
|
|
|
|
// Нет username, используем имя + ID
|
|
|
|
|
|
const cleanName = firstName.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
|
|
|
|
username = `${cleanName}_${telegramId}`;
|
2026-02-09 05:06:46 +03:00
|
|
|
|
usernameSource = 'firstName_telegramId';
|
2026-02-04 05:03:56 +03:00
|
|
|
|
} else if (telegramId) {
|
|
|
|
|
|
// Только ID
|
|
|
|
|
|
username = `user_${telegramId}`;
|
2026-02-09 05:06:46 +03:00
|
|
|
|
usernameSource = 'telegramId_only';
|
2026-02-04 05:03:56 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
// Для тестирования вне Telegram
|
|
|
|
|
|
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
2026-02-09 05:06:46 +03:00
|
|
|
|
usernameSource = 'random_timestamp';
|
2026-02-04 05:03:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 05:06:46 +03:00
|
|
|
|
logger.info('👤 USERNAME GENERATION:', {
|
|
|
|
|
|
source: usernameSource,
|
|
|
|
|
|
username,
|
|
|
|
|
|
input: { telegramId, telegramUsername, firstName, lastName }
|
|
|
|
|
|
});
|
2026-02-08 09:18:55 +03:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-02-04 05:03:56 +03:00
|
|
|
|
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: {}
|
|
|
|
|
|
},
|
2026-02-08 09:18:55 +03:00
|
|
|
|
inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS
|
|
|
|
|
|
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
|
2026-02-04 05:03:56 +03:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!createUserResponse.ok) {
|
|
|
|
|
|
const errorText = await createUserResponse.text();
|
|
|
|
|
|
throw new Error(`Failed to create user: ${errorText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const userData = await createUserResponse.json();
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// 6. Получаем subscription token
|
2026-02-04 05:03:56 +03:00
|
|
|
|
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// 7. Track referral if referrerId provided
|
2026-02-06 20:51:40 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 05:03:56 +03:00
|
|
|
|
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 }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
function getPlanConfig(planType: PlanType, period: Period = '1month') {
|
2026-02-04 05:03:56 +03:00
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// Базовые параметры тарифов
|
|
|
|
|
|
const basePlans = {
|
2026-02-04 05:03:56 +03:00
|
|
|
|
trial: {
|
|
|
|
|
|
name: '7 дней бесплатно',
|
2026-02-08 09:18:55 +03:00
|
|
|
|
dataLimitGB: 0, // безлимит
|
|
|
|
|
|
durationDays: 7,
|
2026-02-04 05:03:56 +03:00
|
|
|
|
},
|
|
|
|
|
|
start: {
|
|
|
|
|
|
name: 'Базовый',
|
2026-02-08 09:18:55 +03:00
|
|
|
|
dataLimitGB: 50,
|
|
|
|
|
|
durationDays: 30,
|
2026-02-04 05:03:56 +03:00
|
|
|
|
},
|
|
|
|
|
|
plus: {
|
|
|
|
|
|
name: 'Расширенный',
|
2026-02-08 09:18:55 +03:00
|
|
|
|
dataLimitGB: 299,
|
|
|
|
|
|
durationDays: 30,
|
2026-02-04 05:03:56 +03:00
|
|
|
|
},
|
|
|
|
|
|
max: {
|
|
|
|
|
|
name: 'Премиум',
|
2026-02-08 09:18:55 +03:00
|
|
|
|
dataLimitGB: 0, // безлимит
|
|
|
|
|
|
durationDays: 30,
|
2026-02-04 05:03:56 +03:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-08 09:18:55 +03:00
|
|
|
|
// Множители для периодов
|
|
|
|
|
|
const periodMultipliers: Record<Period, number> = {
|
|
|
|
|
|
'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,
|
|
|
|
|
|
};
|
2026-02-04 05:03:56 +03:00
|
|
|
|
}
|