Files
app_umbrix/app/api/create-user/route.ts
Umbrix Dev 5677b48227 🔓 Разрешить n8n переходы без Telegram ID
 Что сделано:
- app/page.tsx: Проверка source/utm_source параметров (n8n, chat, from_n8n)
- app/plans/page.tsx: Аналогичная проверка для 3-step flow
- app/api/create-user/route.ts: Разрешение создания без telegramId если source=n8n

🔓 ИСКЛЮЧЕНИЯ для n8n:
- ?source=n8n
- ?utm_source=n8n
- ?utm_source=chat
- ?from_n8n=true

🎯 ЛОГИКА:
1. Переход через Telegram Mini App → требуется tgUser.id 
2. Переход из n8n (прямая ссылка) → НЕ требуется, генерируется временный ID 
3. Переход из браузера напрямую → блокируется 

💡 Для временных ID используется Date.now() но только если fromN8n=true
2026-02-09 07:17:01 +03:00

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
source // Источник перехода (n8n, chat и т.д.)
} = await request.json();
logger.debug('📥 Received data:', {
planType,
period,
locationIds,
telegramId,
telegramUsername,
firstName,
lastName,
referrerId,
source
});
// 🔓 ИСКЛЮЧЕНИЕ: Разрешаем n8n переходы даже без telegramId
const fromN8n = source === 'n8n' || source === 'chat';
// ⚠️ ВАЛИДАЦИЯ: telegramId обязателен (кроме n8n переходов)
if (!telegramId && !fromN8n) {
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 }
);
}
// 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<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,
};
}